From 96b2b0ceeacb6f1f373042e3be545215e58130ab Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:06:12 +0900 Subject: [PATCH 01/11] =?UTF-8?q?:sparkles:=20feat=20:=20configuration=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/runimo/runimo/config/CacheConfig.java | 52 +++++++++++++++++++ .../CustomAuthenticationFailureHandler.java | 33 ++++++++++++ .../runimo/runimo/config/SecurityConfig.java | 34 ++++++++++++ .../runimo/runimo/config/SwaggerConfig.java | 29 +++++++++++ 4 files changed, 148 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/config/CacheConfig.java create mode 100644 src/main/java/org/runimo/runimo/config/CustomAuthenticationFailureHandler.java create mode 100644 src/main/java/org/runimo/runimo/config/SecurityConfig.java create mode 100644 src/main/java/org/runimo/runimo/config/SwaggerConfig.java diff --git a/src/main/java/org/runimo/runimo/config/CacheConfig.java b/src/main/java/org/runimo/runimo/config/CacheConfig.java new file mode 100644 index 00000000..3da7dc5d --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/CacheConfig.java @@ -0,0 +1,52 @@ +package org.runimo.runimo.config; + +import com.sun.security.auth.UserPrincipal; +import org.runimo.runimo.auth.jwt.TokenStatus; +import org.runimo.runimo.common.InMemoryCache; +import org.runimo.runimo.common.SpringInMemoryCache; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +import java.time.Duration; + +@Configuration +public class CacheConfig { + + @Value("${cache.cleanup.interval:300}") + private int cleanupIntervalSeconds; + + @Value("${cache.cleanup.thread-pool-size:1}") + private int cleanupThreadPoolSize; + + @Bean + public TaskScheduler cacheCleanupScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(cleanupThreadPoolSize); + scheduler.setThreadNamePrefix("cache-cleanup-"); + scheduler.setDaemon(true); + scheduler.setWaitForTasksToCompleteOnShutdown(true); + scheduler.setAwaitTerminationSeconds(10); + return scheduler; + } + + @Bean + public InMemoryCache tokenStatusCache(TaskScheduler cacheCleanupScheduler) { + return new SpringInMemoryCache<>( + cacheCleanupScheduler, + Duration.ofSeconds(cleanupIntervalSeconds) + ); + } + + @Bean + public InMemoryCache userPrincipalCache( + TaskScheduler cacheCleanupScheduler + ) { + return new SpringInMemoryCache<>( + cacheCleanupScheduler, + Duration.ofSeconds(cleanupIntervalSeconds) + ); + } +} diff --git a/src/main/java/org/runimo/runimo/config/CustomAuthenticationFailureHandler.java b/src/main/java/org/runimo/runimo/config/CustomAuthenticationFailureHandler.java new file mode 100644 index 00000000..513ffe26 --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/CustomAuthenticationFailureHandler.java @@ -0,0 +1,33 @@ +package org.runimo.runimo.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + Map errorDetails = new HashMap<>(); + errorDetails.put("error", "Authentication Failed"); + errorDetails.put("message", exception.getMessage()); + + new ObjectMapper().writeValue(response.getWriter(), errorDetails); + } +} + diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java new file mode 100644 index 00000000..9e71717e --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package org.runimo.runimo.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) // API 기반 서비스의 경우 CSRF 비활성화 (필요에 따라 조정) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/login**", "/error**", "/auth/**", "/oauth2/**").permitAll() + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/v3/api-docs.yaml", "/v3/api-docs.html").permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .loginPage("/login") + ) + .formLogin(form -> form + .loginPage("/login") + .permitAll() + ); + return http.build(); + } + +} diff --git a/src/main/java/org/runimo/runimo/config/SwaggerConfig.java b/src/main/java/org/runimo/runimo/config/SwaggerConfig.java new file mode 100644 index 00000000..387ed06c --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package org.runimo.runimo.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Runimo User API") + .version("1.0") + .description("Runimo 프로젝트의 사용자 인증 API 문서입니다.") + .contact(new Contact() + .name("Runimo Support") + .email("support@runimo.org")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0")) + ); + } +} + From a4ddabd47dc741c140d265aa200a2fa6e7f37bf2 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:06:52 +0900 Subject: [PATCH 02/11] =?UTF-8?q?:sparkles:=20feat=20:=20common=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/runimo/runimo/common/BaseEntity.java | 29 +++++ .../org/runimo/runimo/common/CacheEntry.java | 24 ++++ .../org/runimo/runimo/common/EnumValid.java | 3 + .../runimo/runimo/common/InMemoryCache.java | 23 ++++ .../org/runimo/runimo/common/Response.java | 25 ++++ .../runimo/common/SpringInMemoryCache.java | 114 ++++++++++++++++++ .../runimo/runimo/common/SuccessResponse.java | 23 ++++ 7 files changed, 241 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/common/BaseEntity.java create mode 100644 src/main/java/org/runimo/runimo/common/CacheEntry.java create mode 100644 src/main/java/org/runimo/runimo/common/InMemoryCache.java create mode 100644 src/main/java/org/runimo/runimo/common/Response.java create mode 100644 src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java create mode 100644 src/main/java/org/runimo/runimo/common/SuccessResponse.java diff --git a/src/main/java/org/runimo/runimo/common/BaseEntity.java b/src/main/java/org/runimo/runimo/common/BaseEntity.java new file mode 100644 index 00000000..82237b96 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/BaseEntity.java @@ -0,0 +1,29 @@ +package org.runimo.runimo.common; + +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@MappedSuperclass +public abstract class BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + @CreationTimestamp + protected LocalDateTime createdAt; + + @UpdateTimestamp + protected LocalDateTime updatedAt; + + protected LocalDateTime deletedAt; +} diff --git a/src/main/java/org/runimo/runimo/common/CacheEntry.java b/src/main/java/org/runimo/runimo/common/CacheEntry.java new file mode 100644 index 00000000..42e35050 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/CacheEntry.java @@ -0,0 +1,24 @@ +package org.runimo.runimo.common; + +import org.springframework.lang.Nullable; + +import java.time.Duration; +import java.time.Instant; + +public record CacheEntry( + V value, + @Nullable + Instant expiresAt +) { + public static CacheEntry permanent(V value) { + return new CacheEntry<>(value, null); + } + + public static CacheEntry withTtl(V value, Duration ttl) { + return new CacheEntry<>(value, Instant.now().plus(ttl)); + } + + public boolean isExpired() { + return expiresAt != null && expiresAt.isBefore(Instant.now()); + } +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/common/EnumValid.java b/src/main/java/org/runimo/runimo/common/EnumValid.java index e4bca78e..91003d07 100644 --- a/src/main/java/org/runimo/runimo/common/EnumValid.java +++ b/src/main/java/org/runimo/runimo/common/EnumValid.java @@ -13,7 +13,10 @@ @Retention(RetentionPolicy.RUNTIME) public @interface EnumValid { String message() default "Invalid enum value"; + Class[] groups() default {}; + Class[] payload() default {}; + Class> enumClass(); } \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/common/InMemoryCache.java b/src/main/java/org/runimo/runimo/common/InMemoryCache.java new file mode 100644 index 00000000..783eaaaa --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/InMemoryCache.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.common; + +import java.time.Duration; +import java.util.Optional; + +public interface InMemoryCache { + + void put(K key, V value); + + void put(K key, V value, Duration ttl); + + boolean putIfAbsent(K key, V value); + + boolean putIfAbsent(K key, V value, Duration ttl); + + Optional get(K key); + + Optional> getEntry(K key); + + boolean remove(K key); + + boolean remove(K key, V value); +} diff --git a/src/main/java/org/runimo/runimo/common/Response.java b/src/main/java/org/runimo/runimo/common/Response.java new file mode 100644 index 00000000..cd3b93b5 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/Response.java @@ -0,0 +1,25 @@ +package org.runimo.runimo.common; + +import lombok.Getter; +import org.runimo.runimo.exceptions.code.CustomResponseCode; + +@Getter +public class Response { + + private final boolean success; + private final String message; + private final String code; + + protected Response(final boolean success, final String message, final String code) { + this.success = success; + this.message = message; + this.code = code; + } + + protected Response(final boolean success, final CustomResponseCode code) { + this.success = success; + this.message = code.getClientMessage(); + this.code = code.getCode(); + } +} + diff --git a/src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java b/src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java new file mode 100644 index 00000000..94123d7e --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java @@ -0,0 +1,114 @@ +package org.runimo.runimo.common; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.scheduling.TaskScheduler; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +public class SpringInMemoryCache implements InMemoryCache, InitializingBean, DisposableBean { + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + private final TaskScheduler taskScheduler; + private final Duration cleanupInterval; + private ScheduledFuture cleanupTask; + + public SpringInMemoryCache(TaskScheduler taskScheduler, Duration cleanupInterval) { + this.taskScheduler = taskScheduler; + this.cleanupInterval = cleanupInterval; + } + + @Override + public void put(K key, V value) { + cache.put(key, CacheEntry.permanent(value)); + } + + @Override + public void put(K key, V value, Duration ttl) { + cache.put(key, CacheEntry.withTtl(value, ttl)); + } + + @Override + public boolean putIfAbsent(K key, V value) { + return cache.putIfAbsent(key, CacheEntry.permanent(value)) == null; + } + + @Override + public boolean putIfAbsent(K key, V value, Duration ttl) { + CacheEntry newEntry = CacheEntry.withTtl(value, ttl); + CacheEntry existingEntry = cache.putIfAbsent(key, newEntry); + + if (existingEntry != null) { + if (existingEntry.isExpired()) { + cache.put(key, newEntry); + return true; + } + return false; + } + return true; + } + + @Override + public Optional get(K key) { + CacheEntry entry = cache.get(key); + if (entry == null) { + return Optional.empty(); + } + + if (entry.isExpired()) { + cache.remove(key); + return Optional.empty(); + } + + return Optional.of(entry.value()); + } + + public Optional> getEntry(K key) { + CacheEntry entry = cache.get(key); + if (entry == null || entry.isExpired()) { + if (entry != null && entry.isExpired()) { + cache.remove(key); + } + return Optional.empty(); + } + return Optional.of(entry); + } + + @Override + public boolean remove(K key) { + return cache.remove(key) != null; + } + + @Override + public boolean remove(K key, V value) { + CacheEntry entry = cache.get(key); + if (entry == null || !entry.value().equals(value)) { + return false; + } + return cache.remove(key) != null; + } + + public void cleanup() { + cache.entrySet().removeIf(entry -> + entry.getValue().expiresAt() != null && + entry.getValue().isExpired()); + } + + @Override + public void afterPropertiesSet() { + this.cleanupTask = taskScheduler.scheduleAtFixedRate( + this::cleanup, + cleanupInterval + ); + } + + @Override + public void destroy() { + if (cleanupTask != null) { + cleanupTask.cancel(false); + } + } +} + diff --git a/src/main/java/org/runimo/runimo/common/SuccessResponse.java b/src/main/java/org/runimo/runimo/common/SuccessResponse.java new file mode 100644 index 00000000..a0c16f4f --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/SuccessResponse.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.common; + +import lombok.Getter; +import org.runimo.runimo.exceptions.code.CustomResponseCode; + +@Getter +public class SuccessResponse extends Response { + + private final T payload; + + private SuccessResponse(final CustomResponseCode code, final T payload) { + super(true, code); + this.payload = payload; + } + + public static SuccessResponse messageOnly(final CustomResponseCode code) { + return new SuccessResponse<>(code, null); + } + + public static SuccessResponse of(final CustomResponseCode code, final T payload) { + return new SuccessResponse<>(code, payload); + } +} From 6af2e3bf8c7be2a61575c95bb5bc0ff56da00dfd Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:07:04 +0900 Subject: [PATCH 03/11] =?UTF-8?q?:sparkles:=20feat=20:=20exceptions=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/runimo/runimo/exceptions/code/ExampleErrorCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/runimo/runimo/exceptions/code/ExampleErrorCode.java b/src/main/java/org/runimo/runimo/exceptions/code/ExampleErrorCode.java index 6f1e5bb9..d56e540b 100644 --- a/src/main/java/org/runimo/runimo/exceptions/code/ExampleErrorCode.java +++ b/src/main/java/org/runimo/runimo/exceptions/code/ExampleErrorCode.java @@ -6,7 +6,7 @@ public enum ExampleErrorCode implements CustomResponseCode { EXAMPLE("C001", HttpStatus.INTERNAL_SERVER_ERROR, "예시 에러코드 예시", "예시로 생성한 에러 발생"), SUCCESS("C002", HttpStatus.OK, "예시 성공코드 예시", "성공 시에 생성한 로그"), - BAD_REQUEST("C003",HttpStatus.BAD_REQUEST, "잘못된 요청", "잘못된 요청 발생"); + BAD_REQUEST("C003", HttpStatus.BAD_REQUEST, "잘못된 요청", "잘못된 요청 발생"); private final String code; private final HttpStatus httpStatusCode; From a702864a589e0ede3e5316db389ce0dd6cdd394a Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:15:08 +0900 Subject: [PATCH 04/11] =?UTF-8?q?:sparkles:=20feat=20:=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20oidc=20=EC=9D=B8=EC=A6=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runimo/auth/jwt/JwtTokenFactory.java | 56 ++++++++++ .../runimo/runimo/auth/jwt/TokenStatus.java | 12 ++ .../InMemoryOAuthTokenRepository.java | 47 ++++++++ .../auth/repository/OAuthTokenRepository.java | 19 ++++ .../runimo/auth/service/OidcNonceService.java | 60 ++++++++++ .../runimo/auth/service/OidcService.java | 26 +++++ .../auth/verifier/KakaoTokenVerifier.java | 103 ++++++++++++++++++ .../auth/verifier/OidcTokenVerifier.java | 8 ++ 8 files changed, 331 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java create mode 100644 src/main/java/org/runimo/runimo/auth/jwt/TokenStatus.java create mode 100644 src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java create mode 100644 src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java create mode 100644 src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java create mode 100644 src/main/java/org/runimo/runimo/auth/service/OidcService.java create mode 100644 src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java create mode 100644 src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java new file mode 100644 index 00000000..d2851c53 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java @@ -0,0 +1,56 @@ +package org.runimo.runimo.auth.jwt; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.service.dtos.TokenPair; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenFactory { + + private static final String ISSUER = "RUNIMO_SERVICE"; + + @Value("${jwt.secret}") + private String jwtSecret; + @Value("${jwt.expiration}") + private long jwtExpiration; + @Value("${jwt.refresh.expiration}") + private long jwtRefreshExpiration; + + public String generateAccessToken(User user) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtExpiration); + + return JWT.create() + .withSubject(user.getPublicId()) + .withIssuedAt(now) + .withExpiresAt(expiryDate) + .withIssuer(ISSUER) + .sign(Algorithm.HMAC256(jwtSecret)); + } + + public String generateRefreshToken(User user) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtRefreshExpiration); + + return JWT.create() + .withSubject(user.getPublicId()) + .withIssuedAt(now) + .withExpiresAt(expiryDate) + .withIssuer(ISSUER) + .withClaim("tokenType", "refresh") + .sign(Algorithm.HMAC256(jwtSecret)); + } + + public TokenPair generateTokenPair(User user) { + String accessToken = generateAccessToken(user); + String refreshToken = generateRefreshToken(user); + return new TokenPair(accessToken, refreshToken); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/jwt/TokenStatus.java b/src/main/java/org/runimo/runimo/auth/jwt/TokenStatus.java new file mode 100644 index 00000000..2c0d1ea4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/jwt/TokenStatus.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.auth.jwt; + +public enum TokenStatus { + /** + * 토큰에 문제가 없는지 검증을 시도한 적 있으나, 서비스 토큰 발급까지 진행되지 않은 상태 + */ + PENDING, + /** + * 토큰에 문제가 없는지 검증이 완료되고, 서비스 토큰 발급까지 완료된 상태 + */ + USED +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java new file mode 100644 index 00000000..e01cd099 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java @@ -0,0 +1,47 @@ +package org.runimo.runimo.auth.repository; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.jwt.TokenStatus; +import org.runimo.runimo.common.CacheEntry; +import org.runimo.runimo.common.InMemoryCache; +import org.runimo.runimo.user.domain.SocialProvider; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class InMemoryOAuthTokenRepository implements OAuthTokenRepository { + + private final InMemoryCache tokenCache; + + public void storeNonce( + SocialProvider provider, String sub, String nonce, TokenStatus status, Duration ttl) { + String key = generateKey(provider, sub, nonce); + tokenCache.put(key, status, ttl); + } + + public Optional getNonceStatus(SocialProvider provider, String sub, String nonce) { + String key = generateKey(provider, sub, nonce); + return tokenCache.get(key); + } + + public void updateNonceStatus( + SocialProvider provider, String sub, String nonce, TokenStatus status) { + String key = generateKey(provider, sub, nonce); + CacheEntry entry = + tokenCache + .getEntry(key) + .orElseThrow(() -> new IllegalStateException("Token not found for key: " + key)); + + tokenCache.remove(key); + + Duration remainingTtl = Duration.between(java.time.Instant.now(), entry.expiresAt()); + tokenCache.put(key, status, remainingTtl); + } + + private String generateKey(SocialProvider provider, String sub, String nonce) { + return String.format("auth:%s:%s:%s", provider.name(), sub, nonce); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java new file mode 100644 index 00000000..7b2e27c9 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/repository/OAuthTokenRepository.java @@ -0,0 +1,19 @@ +package org.runimo.runimo.auth.repository; + +import org.runimo.runimo.auth.jwt.TokenStatus; +import org.runimo.runimo.user.domain.SocialProvider; + +import java.time.Duration; +import java.util.Optional; + +public interface OAuthTokenRepository { + + void storeNonce( + SocialProvider socialProvider, String sub, String nonce, + TokenStatus status, Duration ttl + ); + + Optional getNonceStatus(SocialProvider socialProvider, String sub, String nonce); + + void updateNonceStatus(SocialProvider socialProvider, String sub, String nonce, TokenStatus status); +} diff --git a/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java b/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java new file mode 100644 index 00000000..94f96261 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java @@ -0,0 +1,60 @@ +package org.runimo.runimo.auth.service; + +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.jwt.TokenStatus; +import org.runimo.runimo.auth.repository.OAuthTokenRepository; +import org.runimo.runimo.user.domain.SocialProvider; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class OidcNonceService { + + private final OAuthTokenRepository oAuthTokenRepository; + private final static String NONCE_CLAIM_KEY = "nonce"; + + public void checkNonceAndSave(final SocialProvider provider, final DecodedJWT decodedJWT) { + Optional existingOidcTokenEntry = oAuthTokenRepository.getNonceStatus( + provider, + decodedJWT.getSubject(), + decodedJWT.getClaim(NONCE_CLAIM_KEY).asString() + ); + validateEntryExistsAndUnUsed(existingOidcTokenEntry); + storeNonceWithTTL(provider, decodedJWT, getTTLForNonce(decodedJWT)); + } + + private Duration getTTLForNonce(DecodedJWT decodedJWT) { + return Duration.between(Instant.now(), decodedJWT.getExpiresAtAsInstant()); + } + + /** + * Nonce가 없거나 이미 사용되었다면 탈취의 위험이 있으므로 에러를 반환 + */ + private void validateEntryExistsAndUnUsed(Optional status) { + if (status.isEmpty()) { + throw new IllegalStateException("nonce is not found"); + } + if (status.get() == TokenStatus.USED) { + throw new IllegalStateException("nonce is already used"); + } + } + + private void storeNonceWithTTL(final SocialProvider provider, final DecodedJWT decodedJWT, final Duration ttl) { + oAuthTokenRepository.storeNonce( + provider, + decodedJWT.getSubject(), + String.valueOf(decodedJWT.getClaim(NONCE_CLAIM_KEY)), + TokenStatus.PENDING, + ttl + ); + } + + public void useNonce(DecodedJWT token, SocialProvider provider) { + oAuthTokenRepository.updateNonceStatus(provider, token.getSubject(), String.valueOf(token.getClaim(NONCE_CLAIM_KEY)), TokenStatus.USED); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/service/OidcService.java b/src/main/java/org/runimo/runimo/auth/service/OidcService.java new file mode 100644 index 00000000..aaa99870 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/OidcService.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.auth.service; + +import com.auth0.jwt.interfaces.DecodedJWT; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.verifier.KakaoTokenVerifier; +import org.runimo.runimo.user.domain.SocialProvider; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OidcService { + + private final KakaoTokenVerifier verifier; + private final OidcNonceService nonceService; + + public String validateOidcTokenAndGetProviderId(final DecodedJWT token, final SocialProvider provider) { + DecodedJWT verifyResult; + // APPLE 로그인 추가 예정. + switch (provider) { + case KAKAO -> verifyResult = verifier.verifyToken(token); + default -> throw new IllegalStateException("not supported provider"); + } + nonceService.checkNonceAndSave(provider, verifyResult); + return verifyResult.getSubject(); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java new file mode 100644 index 00000000..360c0be4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java @@ -0,0 +1,103 @@ +package org.runimo.runimo.auth.verifier; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.repository.OAuthTokenRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KakaoTokenVerifier implements OidcTokenVerifier { + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String appKey; + private final OAuthTokenRepository oAuthTokenRepository; + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map publicKeys = new HashMap<>(); + + @Scheduled(fixedRate = 3600000) + public void refreshPublicKeys() { + String jwksUrl = "https://kauth.kakao.com/.well-known/jwks.json"; + String jwksResponse = restTemplate.getForObject(jwksUrl, String.class); + try { + JsonNode jwks = objectMapper.readTree(jwksResponse); + for (JsonNode key : jwks.get("keys")) { + String kid = key.get("kid").asText(); + String n = key.get("n").asText(); + String e = key.get("e").asText(); + + RSAPublicKey publicKey = createPublicKey(n, e); + + publicKeys.put(kid, publicKey); + } + } catch (Exception e) { + throw new RuntimeException("Failed to refresh public keys", e); + } + } + + private RSAPublicKey createPublicKey(String modulusBase64, String exponentBase64) + throws Exception { + byte[] modulusBytes = Base64.getUrlDecoder().decode(modulusBase64); + byte[] exponentBytes = Base64.getUrlDecoder().decode(exponentBase64); + + BigInteger modulus = new BigInteger(1, modulusBytes); + BigInteger exponent = new BigInteger(1, exponentBytes); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) factory.generatePublic(spec); + } + + @Override + public DecodedJWT verifyToken(DecodedJWT token) { + try { + String nonce = token.getClaim("nonce").asString(); + + if (nonce == null) { + throw new JWTVerificationException("Token nonce is missing"); + } + + RSAPublicKey publicKey = publicKeys.get(token.getKeyId()); + if (publicKey == null) { + throw new JWTVerificationException("Unable to find appropriate key"); + } + + Algorithm algorithm = Algorithm.RSA256(publicKey, null); + + return JWT.require(algorithm) + .withIssuer("https://kauth.kakao.com") + .withAudience(appKey) + .build() + .verify(token); + } catch (JWTVerificationException exception) { + throw new IllegalArgumentException("ID token verification failed", exception); + } + } + + /* + * 카카오 토큰 검증을 위한 공개키를 의존성 주입 이후에 조회하여 캐싱하는 메소드 + * */ + @PostConstruct + public void initKakaoTokenVerifier() { + refreshPublicKeys(); + } +} + diff --git a/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java new file mode 100644 index 00000000..04f2d8fa --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/verifier/OidcTokenVerifier.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.auth.verifier; + +import com.auth0.jwt.interfaces.DecodedJWT; + +public interface OidcTokenVerifier { + DecodedJWT verifyToken(DecodedJWT token); + +} From 1ca69ed7dbea4fe2e8bee7cb15ec7858d1164eb5 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:25:35 +0900 Subject: [PATCH 05/11] =?UTF-8?q?:sparkles:=20feat=20:=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85,=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/UserController.java | 76 +++++++++++++++++++ .../controller/request/AuthLoginRequest.java | 11 +++ .../controller/request/AuthSignupRequest.java | 28 +++++++ .../runimo/runimo/user/domain/OAuthInfo.java | 41 ++++++++++ .../runimo/user/domain/SocialProvider.java | 5 ++ .../org/runimo/runimo/user/domain/User.java | 34 +++++++++ .../runimo/user/enums/AuthResultType.java | 9 +++ .../user/enums/UserHttpResponseCode.java | 43 +++++++++++ .../user/repository/OAuthInfoRepository.java | 16 ++++ .../user/repository/UserRepository.java | 10 +++ .../runimo/user/service/UserOAuthService.java | 66 ++++++++++++++++ .../user/service/dtos/AuthResponse.java | 11 +++ .../user/service/dtos/SignupUserInfo.java | 7 ++ .../runimo/user/service/dtos/TokenPair.java | 5 ++ .../user/service/dtos/UserSignupCommand.java | 10 +++ .../service/usecases/UserOAuthUsecase.java | 12 +++ 16 files changed, 384 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/user/controller/UserController.java create mode 100644 src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java create mode 100644 src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java create mode 100644 src/main/java/org/runimo/runimo/user/domain/OAuthInfo.java create mode 100644 src/main/java/org/runimo/runimo/user/domain/SocialProvider.java create mode 100644 src/main/java/org/runimo/runimo/user/domain/User.java create mode 100644 src/main/java/org/runimo/runimo/user/enums/AuthResultType.java create mode 100644 src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java create mode 100644 src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java create mode 100644 src/main/java/org/runimo/runimo/user/repository/UserRepository.java create mode 100644 src/main/java/org/runimo/runimo/user/service/UserOAuthService.java create mode 100644 src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java create mode 100644 src/main/java/org/runimo/runimo/user/service/dtos/SignupUserInfo.java create mode 100644 src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java create mode 100644 src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java create mode 100644 src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecase.java diff --git a/src/main/java/org/runimo/runimo/user/controller/UserController.java b/src/main/java/org/runimo/runimo/user/controller/UserController.java new file mode 100644 index 00000000..b1f9a921 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/UserController.java @@ -0,0 +1,76 @@ +package org.runimo.runimo.user.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.SuccessResponse; +import org.runimo.runimo.user.controller.request.AuthLoginRequest; +import org.runimo.runimo.user.controller.request.AuthSignupRequest; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.service.dtos.AuthResponse; +import org.runimo.runimo.user.service.dtos.SignupUserInfo; +import org.runimo.runimo.user.service.dtos.TokenPair; +import org.runimo.runimo.user.service.usecases.UserOAuthUsecase; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +@RestController +@RequestMapping("/api/v1/user") +@RequiredArgsConstructor +public class UserController { + + private final UserOAuthUsecase userOAuthUsecase; + + @Operation(summary = "사용자 로그인", description = "사용자가 OIDC 토큰을 사용하여 로그인합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", + content = @Content(schema = @Schema(implementation = AuthResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AuthLoginRequest request + ) { + TokenPair authResult = userOAuthUsecase.validateAndLogin( + request.oidcToken(), + SocialProvider.valueOf(request.provider()) + ); + return ResponseEntity.ok().body( + SuccessResponse.of( + UserHttpResponseCode.LOGIN_SUCCESS, + new AuthResponse(authResult) + )); + } + + @Operation(summary = "사용자 회원가입 및 로그인", description = "사용자가 OIDC 토큰을 사용하여 회원가입 후 로그인합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "회원가입 성공", + content = @Content(schema = @Schema(implementation = AuthResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "409", description = "이미 존재하는 사용자") + }) + @PostMapping("/signup") + public ResponseEntity> signupAndLogin( + @Valid @RequestBody AuthSignupRequest request) { + SignupUserInfo authResult = userOAuthUsecase.validateAndSignup( + request.toUserSignupCommand(), + request.oidcToken(), + SocialProvider.valueOf(request.provider()) + ); + return ResponseEntity.created(URI.create("/api/v1/user" + authResult.userId())) + .body(SuccessResponse.of( + UserHttpResponseCode.SIGNUP_SUCCESS, + new AuthResponse(authResult.tokenPair()))); + } +} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java new file mode 100644 index 00000000..26763807 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.user.controller.request; + +import jakarta.validation.constraints.NotBlank; +import org.runimo.runimo.common.EnumValid; +import org.runimo.runimo.user.domain.SocialProvider; + +public record AuthLoginRequest( + @NotBlank String oidcToken, + @NotBlank @EnumValid(enumClass = SocialProvider.class) String provider +) { +} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java new file mode 100644 index 00000000..3c870518 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/request/AuthSignupRequest.java @@ -0,0 +1,28 @@ +package org.runimo.runimo.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.URL; +import org.runimo.runimo.common.EnumValid; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.service.dtos.UserSignupCommand; + +@Schema(description = "사용자 회원가입 요청 DTO") +public record AuthSignupRequest( + + @Schema(description = "OIDC ID 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") + @NotBlank String oidcToken, + + @Schema(description = "소셜 로그인 제공자 (APPLE, KAKAO)", example = "APPLE") + @NotBlank @EnumValid(enumClass = SocialProvider.class) String provider, + + @Schema(description = "사용자 닉네임", example = "RunimoUser") + @NotBlank String nickname, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/image.jpg") + @URL String imgUrl +) { + public UserSignupCommand toUserSignupCommand() { + return new UserSignupCommand(nickname, SocialProvider.valueOf(provider), imgUrl); + } +} diff --git a/src/main/java/org/runimo/runimo/user/domain/OAuthInfo.java b/src/main/java/org/runimo/runimo/user/domain/OAuthInfo.java new file mode 100644 index 00000000..d14c1323 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/OAuthInfo.java @@ -0,0 +1,41 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; + +@Entity +@Table(name = "oauth_accounts") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthInfo extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private SocialProvider provider; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + /*** + * OAuthInfo Constructor + * @param user OAuth연동할 유저 엔티티 + * @param provider 소셜 로그인 제공자 + * @param providerId 소셜 로그인 이메일 / guid + */ + @Builder + public OAuthInfo( + @NotNull User user, @NotNull SocialProvider provider, @NotNull String providerId) { + this.user = user; + this.provider = provider; + this.providerId = providerId; + } +} diff --git a/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java b/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java new file mode 100644 index 00000000..e5bfeb78 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/SocialProvider.java @@ -0,0 +1,5 @@ +package org.runimo.runimo.user.domain; + +public enum SocialProvider { + KAKAO +} diff --git a/src/main/java/org/runimo/runimo/user/domain/User.java b/src/main/java/org/runimo/runimo/user/domain/User.java new file mode 100644 index 00000000..44199104 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/domain/User.java @@ -0,0 +1,34 @@ +package org.runimo.runimo.user.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; + +import java.util.UUID; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + private String publicId; + private String nickname; + private String imgUrl; + + @Builder + public User(String nickname, String imgUrl) { + this.nickname = nickname; + this.imgUrl = imgUrl; + } + + @PrePersist + public void prePersist() { + this.publicId = UUID.randomUUID().toString(); + } +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/enums/AuthResultType.java b/src/main/java/org/runimo/runimo/user/enums/AuthResultType.java new file mode 100644 index 00000000..84ca3ddd --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/enums/AuthResultType.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.user.enums; + +public enum AuthResultType { + LOGIN_SUCCESS, + REFRESH_SUCCESS, + SIGNUP_REQUIRED, + AUTH_FAILED, + SIGNUP_FAILED +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java new file mode 100644 index 00000000..2981ed23 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -0,0 +1,43 @@ +package org.runimo.runimo.user.enums; + +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.springframework.http.HttpStatus; + +public enum UserHttpResponseCode implements CustomResponseCode { + MY_PAGE_DATA_FETCHED("USH2001", "마이페이지 데이터 조회 성공", "마이페이지 데이터 조회 성공"), + SIGNUP_SUCCESS("USH2002", "회원가입 성공", "회원가입 성공"), + LOGIN_SUCCESS("USH2003", "로그인 성공", "로그인 성공"), + REFRESH_SUCCESS("USH2004", "토큰 재발급 성공", "토큰 재발급 성공"), + ; + + private final String code; + private final String clientMessage; + private final String logMessage; + + UserHttpResponseCode(String code, String clientMessage, String logMessage) { + this.code = code; + this.clientMessage = clientMessage; + this.logMessage = logMessage; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getClientMessage() { + return this.clientMessage; + } + + @Override + public String getLogMessage() { + return this.logMessage; + } + + @Override + public HttpStatus getHttpStatusCode() { + return null; + } +} + diff --git a/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java new file mode 100644 index 00000000..093b2efe --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java @@ -0,0 +1,16 @@ +package org.runimo.runimo.user.repository; + +import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface OAuthInfoRepository extends JpaRepository { + + @Query("SELECT o FROM OAuthInfo o WHERE o.provider = :provider AND o.providerId = :providerId") + Optional findByProviderAndProviderId(SocialProvider provider, String providerId); +} diff --git a/src/main/java/org/runimo/runimo/user/repository/UserRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserRepository.java new file mode 100644 index 00000000..5164272f --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.user.repository; + +import org.runimo.runimo.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserOAuthService.java b/src/main/java/org/runimo/runimo/user/service/UserOAuthService.java new file mode 100644 index 00000000..85e06105 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserOAuthService.java @@ -0,0 +1,66 @@ +package org.runimo.runimo.user.service; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.DecodedJWT; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.OidcNonceService; +import org.runimo.runimo.auth.service.OidcService; +import org.runimo.runimo.user.domain.OAuthInfo; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.repository.OAuthInfoRepository; +import org.runimo.runimo.user.repository.UserRepository; +import org.runimo.runimo.user.service.dtos.SignupUserInfo; +import org.runimo.runimo.user.service.dtos.TokenPair; +import org.runimo.runimo.user.service.dtos.UserSignupCommand; +import org.runimo.runimo.user.service.usecases.UserOAuthUsecase; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class UserOAuthService implements UserOAuthUsecase { + private final JwtTokenFactory jwtfactory; + private final OidcService oidcService; + private final OidcNonceService oidcNonceService; + private final OAuthInfoRepository oAuthInfoRepository; + private final UserRepository userRepository; + + @Override + @Transactional + public TokenPair validateAndLogin(final String rawToken, final SocialProvider provider) { + DecodedJWT token = JWT.decode(rawToken); + String pid = oidcService.validateOidcTokenAndGetProviderId(token, provider); + OAuthInfo oAuthInfo = oAuthInfoRepository.findByProviderAndProviderId(provider, pid) + .orElseThrow(() -> new NoSuchElementException("가입된 유저 없음.")); + oidcNonceService.useNonce(token, provider); + return jwtfactory.generateTokenPair(oAuthInfo.getUser()); + } + + @Override + @Transactional + public SignupUserInfo validateAndSignup(final UserSignupCommand command, final String rawToken, SocialProvider provider) { + DecodedJWT token = JWT.decode(rawToken); + String pid = oidcService.validateOidcTokenAndGetProviderId(token, provider); + oAuthInfoRepository.findByProviderAndProviderId(provider, pid) + .ifPresent(oAuthInfo -> { + throw new IllegalArgumentException(); + }); + + User user = User.builder() + .nickname(command.nickname()) + .imgUrl(command.imgUrl()) + .build(); + userRepository.saveAndFlush(user); + OAuthInfo oAuthInfo = new OAuthInfo( + user, + command.provider(), + pid + ); + oAuthInfoRepository.save(oAuthInfo); + return new SignupUserInfo(user.getId(), jwtfactory.generateTokenPair(user)); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java b/src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java new file mode 100644 index 00000000..e4687a34 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/AuthResponse.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.user.service.dtos; + +public record AuthResponse( + String accessToken, + String refreshToken +) { + + public AuthResponse(final TokenPair tokenPair) { + this(tokenPair.accessToken(), tokenPair.refreshToken()); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/SignupUserInfo.java b/src/main/java/org/runimo/runimo/user/service/dtos/SignupUserInfo.java new file mode 100644 index 00000000..3c115e1d --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/SignupUserInfo.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.user.service.dtos; + +public record SignupUserInfo( + Long userId, + TokenPair tokenPair +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java b/src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java new file mode 100644 index 00000000..90a62aa1 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/TokenPair.java @@ -0,0 +1,5 @@ +package org.runimo.runimo.user.service.dtos; + +public record TokenPair(String accessToken, String refreshToken) { +} + diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java b/src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java new file mode 100644 index 00000000..497634a4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/UserSignupCommand.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.user.service.dtos; + +import org.runimo.runimo.user.domain.SocialProvider; + +public record UserSignupCommand( + String nickname, + SocialProvider provider, + String imgUrl +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecase.java new file mode 100644 index 00000000..5796eede --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/UserOAuthUsecase.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.service.dtos.SignupUserInfo; +import org.runimo.runimo.user.service.dtos.TokenPair; +import org.runimo.runimo.user.service.dtos.UserSignupCommand; + +public interface UserOAuthUsecase { + TokenPair validateAndLogin(final String rawToken, final SocialProvider provider); + + SignupUserInfo validateAndSignup(final UserSignupCommand command, final String newToken, final SocialProvider socialProvider); +} From dc2ebae0cff8254821fb2ea1ac14a7d468d75b14 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:26:01 +0900 Subject: [PATCH 06/11] =?UTF-8?q?:sparkles:=20feat=20:=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exceptions/GlobalExceptionHandler.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java diff --git a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java new file mode 100644 index 00000000..236c8f1f --- /dev/null +++ b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package org.runimo.runimo.exceptions; + +import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.common.Response; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.NoSuchElementException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNoSuchElementException(NoSuchElementException e) { + log.debug("ERROR: {}}", e.getMessage(), e); + return ResponseEntity.notFound().build(); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + log.debug("ERROR: {}}", e.getMessage(), e); + return ResponseEntity.badRequest().build(); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalStateException(IllegalStateException e) { + log.debug("ERROR: {}}", e.getMessage(), e); + return ResponseEntity.badRequest().build(); + } +} From ef231eafb214d010a12030dadcebec9dd0010433 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:26:22 +0900 Subject: [PATCH 07/11] =?UTF-8?q?:sparkles:=20feat=20:=20JPA=20Auditing=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/runimo/runimo/RunimoApplication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/runimo/runimo/RunimoApplication.java b/src/main/java/org/runimo/runimo/RunimoApplication.java index 1c40cad6..60e11866 100644 --- a/src/main/java/org/runimo/runimo/RunimoApplication.java +++ b/src/main/java/org/runimo/runimo/RunimoApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class RunimoApplication { public static void main(String[] args) { From de6dc77ad269810d6f4d674f83ea0f56940ce51c Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:27:16 +0900 Subject: [PATCH 08/11] =?UTF-8?q?:heavy=5Fplus=5Fsign:=20chore=20:=20sprin?= =?UTF-8?q?g=20security,=20jwt,=20oauth,=20swagger=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/build.gradle b/build.gradle index 22ba5614..c0e8e778 100644 --- a/build.gradle +++ b/build.gradle @@ -20,15 +20,24 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.6.2' implementation 'me.paulschwarz:spring-dotenv:4.0.0' + implementation 'org.springframework.security:spring-security-oauth2-client' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'com.auth0:java-jwt:4.3.0' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' + testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } From 06d252b75754735ea05878a20b5648af23c04a90 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:27:47 +0900 Subject: [PATCH 09/11] =?UTF-8?q?:wrench:=20chore=20:=20application.yml=20?= =?UTF-8?q?swagger=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e6268008..84e0cba4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -25,11 +25,6 @@ spring: open-in-view: false jackson: property-naming-strategy: SNAKE_CASE - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT} - password: ${REDIS_PASSWORD} security: oauth2: client: @@ -44,7 +39,9 @@ spring: authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v1/oidc/userinfo - +springdoc: + swagger-ui: + path: /swagger-ui.html jwt: secret: ${JWT_SECRET} expiration: ${JWT_EXPIRATION:3600000} @@ -72,9 +69,6 @@ spring: hibernate: dialect: org.hibernate.dialect.MySQLDialect show-sql: true - sql: - init: - mode: always jwt: expiration: 300000 refresh: From 6e0ae6e3023fdce0bace8a40aa3f6634d60acd94 Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:28:33 +0900 Subject: [PATCH 10/11] =?UTF-8?q?:recycle:=20refactor=20:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/runimo/runimo/auth/service/OidcNonceService.java | 2 +- .../runimo/runimo/auth/verifier/KakaoTokenVerifier.java | 8 ++++---- .../runimo/runimo/exceptions/GlobalExceptionHandler.java | 3 --- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java b/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java index 94f96261..b790874e 100644 --- a/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java +++ b/src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java @@ -15,8 +15,8 @@ @RequiredArgsConstructor public class OidcNonceService { - private final OAuthTokenRepository oAuthTokenRepository; private final static String NONCE_CLAIM_KEY = "nonce"; + private final OAuthTokenRepository oAuthTokenRepository; public void checkNonceAndSave(final SocialProvider provider, final DecodedJWT decodedJWT) { Optional existingOidcTokenEntry = oAuthTokenRepository.getNonceStatus( diff --git a/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java b/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java index 360c0be4..93e76b57 100644 --- a/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java +++ b/src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java @@ -26,12 +26,12 @@ @RequiredArgsConstructor public class KakaoTokenVerifier implements OidcTokenVerifier { - @Value("${spring.security.oauth2.client.registration.kakao.client-id}") - private String appKey; private final OAuthTokenRepository oAuthTokenRepository; private final RestTemplate restTemplate = new RestTemplate(); private final ObjectMapper objectMapper = new ObjectMapper(); private final Map publicKeys = new HashMap<>(); + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String appKey; @Scheduled(fixedRate = 3600000) public void refreshPublicKeys() { @@ -93,8 +93,8 @@ public DecodedJWT verifyToken(DecodedJWT token) { } /* - * 카카오 토큰 검증을 위한 공개키를 의존성 주입 이후에 조회하여 캐싱하는 메소드 - * */ + * 카카오 토큰 검증을 위한 공개키를 의존성 주입 이후에 조회하여 캐싱하는 메소드 + * */ @PostConstruct public void initKakaoTokenVerifier() { refreshPublicKeys(); diff --git a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java index 236c8f1f..1ecc767f 100644 --- a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java @@ -1,9 +1,6 @@ package org.runimo.runimo.exceptions; import lombok.extern.slf4j.Slf4j; -import org.runimo.runimo.common.Response; - -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.ExceptionHandler; From f1130d4a67adad22d4dd98a9d027006ebbf0c22d Mon Sep 17 00:00:00 2001 From: ekgns33 Date: Wed, 12 Mar 2025 10:43:06 +0900 Subject: [PATCH 11/11] :bug: fix : add test profile --- src/test/java/org/runimo/runimo/RunimoApplicationTests.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/org/runimo/runimo/RunimoApplicationTests.java b/src/test/java/org/runimo/runimo/RunimoApplicationTests.java index edf8c8b4..eefde3de 100644 --- a/src/test/java/org/runimo/runimo/RunimoApplicationTests.java +++ b/src/test/java/org/runimo/runimo/RunimoApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class RunimoApplicationTests { @Test