From 6117052a4f66887fe0b7633f781607487d16f79c Mon Sep 17 00:00:00 2001 From: hyun731 Date: Wed, 6 May 2026 08:35:23 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20ResponseUtil=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/auth/controller/AuthController.java | 4 ++++ .../auth/global/client/UserServiceClient.java | 4 ++++ .../auth/global/config/SecurityConfig.java | 4 ++++ .../com/example/auth/global/dto/ApiResponse.java | 6 ++++++ .../example/auth/global/util/ResponseUtil.java | 15 +++++++++++++++ .../example/auth/repository/AuthRepository.java | 4 ++++ .../com/example/auth/service/AuthService.java | 4 ++++ 7 files changed, 41 insertions(+) create mode 100644 src/main/java/com/example/auth/controller/AuthController.java create mode 100644 src/main/java/com/example/auth/global/client/UserServiceClient.java create mode 100644 src/main/java/com/example/auth/global/config/SecurityConfig.java create mode 100644 src/main/java/com/example/auth/global/dto/ApiResponse.java create mode 100644 src/main/java/com/example/auth/global/util/ResponseUtil.java create mode 100644 src/main/java/com/example/auth/repository/AuthRepository.java create mode 100644 src/main/java/com/example/auth/service/AuthService.java diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java new file mode 100644 index 0000000..6a122c2 --- /dev/null +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -0,0 +1,4 @@ +package com.example.auth.controller; + +public class AuthController { +} diff --git a/src/main/java/com/example/auth/global/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java new file mode 100644 index 0000000..0411c39 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -0,0 +1,4 @@ +package com.example.auth.global.client; + +public class UserServiceClient { +} diff --git a/src/main/java/com/example/auth/global/config/SecurityConfig.java b/src/main/java/com/example/auth/global/config/SecurityConfig.java new file mode 100644 index 0000000..e5b54eb --- /dev/null +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -0,0 +1,4 @@ +package com.example.auth.global.config; + +public class SecurityConfig { +} diff --git a/src/main/java/com/example/auth/global/dto/ApiResponse.java b/src/main/java/com/example/auth/global/dto/ApiResponse.java new file mode 100644 index 0000000..8e7cd68 --- /dev/null +++ b/src/main/java/com/example/auth/global/dto/ApiResponse.java @@ -0,0 +1,6 @@ +package com.example.auth.global.dto; + +public record ApiResponse ( + String message, + T date +){} \ No newline at end of file diff --git a/src/main/java/com/example/auth/global/util/ResponseUtil.java b/src/main/java/com/example/auth/global/util/ResponseUtil.java new file mode 100644 index 0000000..b87d8c6 --- /dev/null +++ b/src/main/java/com/example/auth/global/util/ResponseUtil.java @@ -0,0 +1,15 @@ +package com.example.auth.global.util; + +import com.example.auth.global.dto.ApiResponse; + +public class ResponseUtil { + public static ApiResponse success(String message,T data){ + return new ApiResponse<>(message,data); + } + public static ApiResponse success(String message){ + return new ApiResponse<>(message,null); + } + public static ApiResponse fail(String message){ + return new ApiResponse<>(message,null); + } +} diff --git a/src/main/java/com/example/auth/repository/AuthRepository.java b/src/main/java/com/example/auth/repository/AuthRepository.java new file mode 100644 index 0000000..3f23b1f --- /dev/null +++ b/src/main/java/com/example/auth/repository/AuthRepository.java @@ -0,0 +1,4 @@ +package com.example.auth.repository; + +public class AuthRepository { +} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java new file mode 100644 index 0000000..01c47cc --- /dev/null +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -0,0 +1,4 @@ +package com.example.auth.service; + +public class AuthService { +} From a1f34e6204a0f606b046a2398e07e3d305cd43b3 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Wed, 6 May 2026 10:06:44 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat:=20ApiReponse=20fail=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/auth/global/util/ResponseUtil.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/com/example/auth/global/util/ResponseUtil.java b/src/main/java/com/example/auth/global/util/ResponseUtil.java index b87d8c6..c1750c1 100644 --- a/src/main/java/com/example/auth/global/util/ResponseUtil.java +++ b/src/main/java/com/example/auth/global/util/ResponseUtil.java @@ -9,7 +9,4 @@ public static ApiResponse success(String message,T data){ public static ApiResponse success(String message){ return new ApiResponse<>(message,null); } - public static ApiResponse fail(String message){ - return new ApiResponse<>(message,null); - } } From 7f19e47a606e5cfe840e74a7bf1dfc1e824607f0 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 08:19:42 +0900 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 + Dockerfile | 20 +++ build.gradle | 7 + docker-compose.yml | 63 +++++++ .../auth/controller/AuthController.java | 43 +++++ .../auth/dto/request/RefreshRequest.java | 6 + .../auth/dto/request/SignInRequest.java | 7 + .../auth/dto/request/SignOutRequest.java | 5 + .../response/OauthGoogleCallbackResponse.java | 10 ++ .../auth/dto/response/RefreshResponse.java | 8 + .../auth/dto/response/SignInResponse.java | 10 ++ .../auth/global/client/UserAuthResponse.java | 11 ++ .../auth/global/client/UserServiceClient.java | 24 +++ .../global/config/PasswordEncoderConfig.java | 16 ++ .../auth/global/config/RedisConfig.java | 9 + .../auth/global/config/SecurityConfig.java | 32 ++++ .../security/JwtAuthenticationFilter.java | 50 ++++++ .../auth/global/security/JwtProvider.java | 114 ++++++++++++ .../auth/global/security/TokenExtractor.java | 24 +++ .../auth/infra/RefreshTokenRepository.java | 37 ++++ .../com/example/auth/service/AuthService.java | 163 ++++++++++++++++++ src/main/resources/application.properties | 23 +++ src/test/resources/application.properties | 6 + 23 files changed, 694 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/example/auth/dto/request/RefreshRequest.java create mode 100644 src/main/java/com/example/auth/dto/request/SignInRequest.java create mode 100644 src/main/java/com/example/auth/dto/request/SignOutRequest.java create mode 100644 src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java create mode 100644 src/main/java/com/example/auth/dto/response/RefreshResponse.java create mode 100644 src/main/java/com/example/auth/dto/response/SignInResponse.java create mode 100644 src/main/java/com/example/auth/global/client/UserAuthResponse.java create mode 100644 src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java create mode 100644 src/main/java/com/example/auth/global/config/RedisConfig.java create mode 100644 src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/example/auth/global/security/JwtProvider.java create mode 100644 src/main/java/com/example/auth/global/security/TokenExtractor.java create mode 100644 src/main/java/com/example/auth/infra/RefreshTokenRepository.java create mode 100644 src/test/resources/application.properties diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8626a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.gradle +build +.git +.idea +*.iml +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b4a2309 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM eclipse-temurin:21-jdk-alpine AS builder + +WORKDIR /app + +COPY gradlew settings.gradle build.gradle ./ +COPY gradle ./gradle +RUN ./gradlew dependencies --no-daemon + +COPY src ./src +RUN ./gradlew bootJar -x test --no-daemon + +FROM eclipse-temurin:21-jre-alpine + +WORKDIR /app + +COPY --from=builder /app/build/libs/*.jar app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index f9693d7..83af5ac 100644 --- a/build.gradle +++ b/build.gradle @@ -21,12 +21,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-data-jdbc-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' testCompileOnly 'org.projectlombok:lombok' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f804bf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + postgres: + image: postgres:16-alpine + container_name: auth-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + volumes: + - auth_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: auth-redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + ports: + - "${REDIS_PORT}:6379" + volumes: + - auth_redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + auth-app: + build: + context: . + dockerfile: Dockerfile + container_name: auth-app + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME} + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD} + JPA_DDL_AUTO: ${JPA_DDL_AUTO} + JWT_SECRET: ${JWT_SECRET} + USER_SERVICE_BASE_URL: ${USER_SERVICE_BASE_URL} + ports: + - "${AUTH_SERVER_PORT}:8080" + +volumes: + auth_postgres_data: + auth_redis_data: diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java index 6a122c2..5c6c08b 100644 --- a/src/main/java/com/example/auth/controller/AuthController.java +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -1,4 +1,47 @@ package com.example.auth.controller; +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.RefreshResponse; +import com.example.auth.dto.response.SignInResponse; +import com.example.auth.global.dto.ApiResponse; +import com.example.auth.global.util.ResponseUtil; +import com.example.auth.service.AuthService; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RestController +@RequestMapping("/auth") public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/signin") + public ApiResponse signin(@RequestBody SignInRequest request) { + // 로그인 성공 시 Access Token과 Refresh Token을 클라이언트에게 내려줍니다. + SignInResponse response = authService.login(request); + return ResponseUtil.success("로그인에 성공했습니다.", response); + } + + @PostMapping("/refresh") + public ApiResponse refresh(@RequestBody RefreshRequest request) { + // Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다. + RefreshResponse response = authService.refresh(request); + return ResponseUtil.success("토큰 재발급에 성공했습니다.", response); + } + + @DeleteMapping("/signout") + public ApiResponse signout(@RequestBody SignOutRequest request) { + // 로그아웃은 클라이언트가 가진 Refresh Token을 저장소에서 제거하는 방식입니다. + authService.logout(request); + return ResponseUtil.success("로그아웃에 성공했습니다."); + } } diff --git a/src/main/java/com/example/auth/dto/request/RefreshRequest.java b/src/main/java/com/example/auth/dto/request/RefreshRequest.java new file mode 100644 index 0000000..487ffaf --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/RefreshRequest.java @@ -0,0 +1,6 @@ +package com.example.auth.dto.request; + +public record RefreshRequest( + String refreshToken +) { +} diff --git a/src/main/java/com/example/auth/dto/request/SignInRequest.java b/src/main/java/com/example/auth/dto/request/SignInRequest.java new file mode 100644 index 0000000..68b0e8c --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/SignInRequest.java @@ -0,0 +1,7 @@ +package com.example.auth.dto.request; + +public record SignInRequest( + String email, + String password +) { +} diff --git a/src/main/java/com/example/auth/dto/request/SignOutRequest.java b/src/main/java/com/example/auth/dto/request/SignOutRequest.java new file mode 100644 index 0000000..dc089b4 --- /dev/null +++ b/src/main/java/com/example/auth/dto/request/SignOutRequest.java @@ -0,0 +1,5 @@ +package com.example.auth.dto.request; + +public record SignOutRequest( + String refresh_token +) {} diff --git a/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java b/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java new file mode 100644 index 0000000..c276fd0 --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/OauthGoogleCallbackResponse.java @@ -0,0 +1,10 @@ +package com.example.auth.dto.response; + +public record OauthGoogleCallbackResponse( + String name, + String role, + String access_token, + String refresh_token, + Number expires_in +) { +} diff --git a/src/main/java/com/example/auth/dto/response/RefreshResponse.java b/src/main/java/com/example/auth/dto/response/RefreshResponse.java new file mode 100644 index 0000000..0f21fda --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/RefreshResponse.java @@ -0,0 +1,8 @@ +package com.example.auth.dto.response; + +public record RefreshResponse( + String access_token, + String refresh_token, + Number expires_in +) { +} diff --git a/src/main/java/com/example/auth/dto/response/SignInResponse.java b/src/main/java/com/example/auth/dto/response/SignInResponse.java new file mode 100644 index 0000000..72df0d8 --- /dev/null +++ b/src/main/java/com/example/auth/dto/response/SignInResponse.java @@ -0,0 +1,10 @@ +package com.example.auth.dto.response; + +public record SignInResponse( + String name, + String role, + String access_token, + String refresh_token, + String expires_in +) { +} diff --git a/src/main/java/com/example/auth/global/client/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/UserAuthResponse.java new file mode 100644 index 0000000..329d6fd --- /dev/null +++ b/src/main/java/com/example/auth/global/client/UserAuthResponse.java @@ -0,0 +1,11 @@ +package com.example.auth.global.client; + +import java.util.List; + +public record UserAuthResponse( + Long userId, + String name, + String role, + List roles +) { +} diff --git a/src/main/java/com/example/auth/global/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java index 0411c39..7fc03a1 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -1,4 +1,28 @@ package com.example.auth.global.client; +import com.example.auth.dto.request.SignInRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component public class UserServiceClient { + + private final RestClient restClient; + + public UserServiceClient( + @Value("${user-service.base-url:http://localhost:8081}") String userServiceBaseUrl //임시 + ) { + this.restClient = RestClient.builder() + .baseUrl(userServiceBaseUrl) + .build(); + } + + public UserAuthResponse authenticate(SignInRequest request) { + return restClient.post() + .uri("/internal/users/authenticate") + .body(request) + .retrieve() + .body(UserAuthResponse.class); + } } diff --git a/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..d27ffc4 --- /dev/null +++ b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java @@ -0,0 +1,16 @@ +package com.example.auth.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + // 비밀번호는 단방향 해시로 저장해야 하므로 BCrypt 인코더를 공용 Bean으로 등록합니다. + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/auth/global/config/RedisConfig.java b/src/main/java/com/example/auth/global/config/RedisConfig.java new file mode 100644 index 0000000..7a20953 --- /dev/null +++ b/src/main/java/com/example/auth/global/config/RedisConfig.java @@ -0,0 +1,9 @@ +package com.example.auth.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { +} diff --git a/src/main/java/com/example/auth/global/config/SecurityConfig.java b/src/main/java/com/example/auth/global/config/SecurityConfig.java index e5b54eb..8a9de36 100644 --- a/src/main/java/com/example/auth/global/config/SecurityConfig.java +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -1,4 +1,36 @@ package com.example.auth.global.config; +import com.example.auth.global.security.JwtAuthenticationFilter; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + // JWT 인증은 세션을 사용하지 않으므로 CSRF와 세션 생성을 끕니다. + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둡니다. + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/signin", "/auth/refresh", "/actuator/health").permitAll() + .anyRequest().authenticated() + ) + // UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리합니다. + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } } diff --git a/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java b/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b46adac --- /dev/null +++ b/src/main/java/com/example/auth/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.example.auth.global.security; + +import io.jsonwebtoken.JwtException; +import java.io.IOException; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final TokenExtractor tokenExtractor; + + public JwtAuthenticationFilter(JwtProvider jwtProvider, TokenExtractor tokenExtractor) { + this.jwtProvider = jwtProvider; + this.tokenExtractor = tokenExtractor; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + // 요청에 Access Token이 있으면 검증 후 SecurityContext에 인증 정보를 저장 + tokenExtractor.extractAccessToken(request) + .ifPresent(this::setAuthentication); + + filterChain.doFilter(request, response); + } catch (JwtException | IllegalArgumentException exception) { + // 토큰이 위조됐거나 만료된 경우 인증 실패 응답을 내려줌 + SecurityContextHolder.clearContext(); + response.sendError(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 JWT 토큰입니다."); + } + } + + private void setAuthentication(String token) { + jwtProvider.validateToken(token); + Authentication authentication = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/example/auth/global/security/JwtProvider.java b/src/main/java/com/example/auth/global/security/JwtProvider.java new file mode 100644 index 0000000..9624e91 --- /dev/null +++ b/src/main/java/com/example/auth/global/security/JwtProvider.java @@ -0,0 +1,114 @@ +package com.example.auth.global.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenExpirationMillis; + private final long refreshTokenExpirationMillis; + + public JwtProvider( + @Value("${jwt.secret:change-this-secret-key-for-local-development-only}") String secret, + @Value("${jwt.access-token-expiration:30m}") Duration accessTokenExpiration, + @Value("${jwt.refresh-token-expiration:14d}") Duration refreshTokenExpiration + ) { + this.secretKey = createSecretKey(secret); + this.accessTokenExpirationMillis = accessTokenExpiration.toMillis(); + this.refreshTokenExpirationMillis = refreshTokenExpiration.toMillis(); + } + + public String createAccessToken(String subject, Collection roles) { + return createToken(subject, roles, accessTokenExpirationMillis); + } + + public String createRefreshToken(String subject, Collection roles) { + return createToken(subject, roles, refreshTokenExpirationMillis); + } + + public long getAccessTokenExpirationMillis() { + return accessTokenExpirationMillis; + } + + public long getRefreshTokenExpirationMillis() { + return refreshTokenExpirationMillis; + } + + // JWT 안의 subject와 roles를 Spring Security가 이해하는 Authentication 객체로 바꿉니다. + public Authentication getAuthentication(String token) { + Claims claims = parseClaims(token); + String subject = claims.getSubject(); + List roles = claims.get("roles", List.class); + + List authorities = roles.stream() + .map(SimpleGrantedAuthority::new) + .toList(); + + return new UsernamePasswordAuthenticationToken(subject, null, authorities); + } + + // 토큰 서명과 만료 시간을 검증합니다. 문제가 있으면 jjwt 예외가 발생합니다. + public boolean validateToken(String token) { + parseClaims(token); + return true; + } + + // 로그아웃이나 재발급에서 토큰 주인을 찾을 때 사용합니다. + public String getSubject(String token) { + return parseClaims(token).getSubject(); + } + + private String createToken(String subject, Collection roles, long expirationMillis) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationMillis); + + return Jwts.builder() + .subject(subject) + .claim("roles", roles) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + private Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey createSecretKey(String secret) { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + + if (keyBytes.length < 32) { + keyBytes = sha256(keyBytes); + } + + return Keys.hmacShaKeyFor(keyBytes); + } + + private byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (Exception exception) { + throw new IllegalStateException("JWT secret key를 생성할 수 없습니다.", exception); + } + } +} diff --git a/src/main/java/com/example/auth/global/security/TokenExtractor.java b/src/main/java/com/example/auth/global/security/TokenExtractor.java new file mode 100644 index 0000000..8c447d9 --- /dev/null +++ b/src/main/java/com/example/auth/global/security/TokenExtractor.java @@ -0,0 +1,24 @@ +package com.example.auth.global.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class TokenExtractor { + + private static final String BEARER_PREFIX = "Bearer "; + + // 헤더에서 토큰 값만 분리 + public Optional extractAccessToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (!StringUtils.hasText(authorizationHeader) || !authorizationHeader.startsWith(BEARER_PREFIX)) { + return Optional.empty(); + } + + return Optional.of(authorizationHeader.substring(BEARER_PREFIX.length())); + } +} diff --git a/src/main/java/com/example/auth/infra/RefreshTokenRepository.java b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java new file mode 100644 index 0000000..d768ee3 --- /dev/null +++ b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java @@ -0,0 +1,37 @@ +package com.example.auth.infra; + +import java.time.Duration; +import java.util.Objects; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +public class RefreshTokenRepository { + + private static final String REFRESH_TOKEN_KEY_PREFIX = "auth:refresh-token:"; + + private final StringRedisTemplate redisTemplate; + + public RefreshTokenRepository(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + // Refresh Token은 서버에도 저장해 재발급과 로그아웃 시 유효성을 확인합니다. + public void save(String subject, String refreshToken, long ttlMillis) { + redisTemplate.opsForValue() + .set(createKey(subject), refreshToken, Duration.ofMillis(ttlMillis)); + } + + public boolean existsBySubjectAndToken(String subject, String refreshToken) { + String storedRefreshToken = redisTemplate.opsForValue().get(createKey(subject)); + return Objects.equals(storedRefreshToken, refreshToken); + } + + public void deleteBySubject(String subject) { + redisTemplate.delete(createKey(subject)); + } + + private String createKey(String subject) { + return REFRESH_TOKEN_KEY_PREFIX + subject; + } +} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 01c47cc..2d6a3b1 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -1,4 +1,167 @@ package com.example.auth.service; +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.RefreshResponse; +import com.example.auth.dto.response.SignInResponse; +import com.example.auth.global.client.UserServiceClient; +import com.example.auth.global.client.UserAuthResponse; +import com.example.auth.global.security.JwtProvider; +import com.example.auth.infra.RefreshTokenRepository; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service public class AuthService { + + private final UserServiceClient userServiceClient; + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + //의존성 주입 + public AuthService( + UserServiceClient userServiceClient, + JwtProvider jwtProvider, + RefreshTokenRepository refreshTokenRepository + ) { + this.userServiceClient = userServiceClient; + this.jwtProvider = jwtProvider; + this.refreshTokenRepository = refreshTokenRepository; + } + + //로그인 + public SignInResponse login(SignInRequest request) { + validateLoginRequest(request); + + // 사용자 검증은 User 서비스에 맡기고, 검증된 사용자 정보로 토큰을 발급합니다. + UserAuthResponse user = userServiceClient.authenticate(request); + validateAuthenticatedUser(user); + + String subject = String.valueOf(user.userId()); + List roles = normalizeRoles(user.roles(), user.role()); + TokenPair tokenPair = issueTokens(subject, roles); + + return new SignInResponse( + user.name(), + firstRole(roles), + tokenPair.accessToken(), + tokenPair.refreshToken(), + String.valueOf(toSeconds(jwtProvider.getAccessTokenExpirationMillis())) + ); + } + + //리프레쉬 토큰 + public RefreshResponse refresh(RefreshRequest request) { + validateRefreshToken(request.refreshToken()); + jwtProvider.validateToken(request.refreshToken()); + + String subject = jwtProvider.getSubject(request.refreshToken()); + + if (!refreshTokenRepository.existsBySubjectAndToken(subject, request.refreshToken())) { + throw new IllegalArgumentException("저장된 Refresh Token과 일치하지 않습니다."); + } + + // Refresh Token이 정상일 때 Access Token과 Refresh Token을 모두 새로 발급합니다. + List roles = jwtProvider.getAuthentication(request.refreshToken()).getAuthorities().stream() + .map(authority -> authority.getAuthority()) + .toList(); + + TokenPair tokenPair = issueTokens(subject, roles); + return new RefreshResponse( + tokenPair.accessToken(), + tokenPair.refreshToken(), + toSeconds(jwtProvider.getAccessTokenExpirationMillis()) + ); + } + + //로그아웃 + public void logout(SignOutRequest request) { + validateSignOutRequest(request); + jwtProvider.validateToken(request.refresh_token()); + String subject = jwtProvider.getSubject(request.refresh_token()); + + // 저장된 Refresh Token을 제거하면 이후 재발급이 불가능해집니다. + refreshTokenRepository.deleteBySubject(subject); + } + + //토큰 발급 + private TokenPair issueTokens(String subject, List roles) { + String accessToken = jwtProvider.createAccessToken(subject, roles); + String refreshToken = jwtProvider.createRefreshToken(subject, roles); + + refreshTokenRepository.save(subject, refreshToken, jwtProvider.getRefreshTokenExpirationMillis()); + + return new TokenPair( + accessToken, + refreshToken + ); + } + + + /* + 내부 함수 + */ + private void validateLoginRequest(SignInRequest request) { + if (request == null || !StringUtils.hasText(request.email()) || !StringUtils.hasText(request.password())) { + throw new IllegalArgumentException("아이디와 비밀번호를 입력해주세요."); + } + } + + private void validateRefreshToken(String refreshToken) { + if (!StringUtils.hasText(refreshToken)) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + private void validateSignOutRequest(SignOutRequest request) { + if (request == null || !StringUtils.hasText(request.refresh_token())) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + private void validateAuthenticatedUser(UserAuthResponse user) { + if (user == null || user.userId() == null) { + throw new IllegalArgumentException("사용자 인증 정보를 확인할 수 없습니다."); + } + } + + private List normalizeRoles(List roles, String role) { + if (roles == null || roles.isEmpty()) { + if (StringUtils.hasText(role)) { + return List.of(normalizeRole(role)); + } + return List.of("ROLE_USER"); + } + + List normalizedRoles = roles.stream() + .filter(StringUtils::hasText) + .map(this::normalizeRole) + .distinct() + .toList(); + + if (normalizedRoles.isEmpty()) { + return List.of("ROLE_USER"); + } + + return normalizedRoles; + } + + private String normalizeRole(String role) { + return role.startsWith("ROLE_") ? role : "ROLE_" + role; + } + + private String firstRole(List roles) { + return roles.isEmpty() ? "ROLE_USER" : roles.getFirst(); + } + + private long toSeconds(long millis) { + return millis / 1000; + } + + private record TokenPair( + String accessToken, + String refreshToken + ) {} } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c90eb26..dd45e62 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,24 @@ spring.application.name=Auth + +# PostgreSQL 연결 정보입니다. Docker Compose에서는 postgres 서비스 이름을 DB_HOST로 사용합니다. +spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:auth_db} +spring.datasource.username=${DB_USERNAME:auth_user} +spring.datasource.password=${DB_PASSWORD:auth_password} +spring.datasource.driver-class-name=org.postgresql.Driver + +# 개발 초기에는 update가 편하고, 운영에서는 validate 또는 none으로 바꾸는 것을 권장합니다. +spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:update} +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Refresh Token 저장과 로그아웃 처리를 위한 Redis 연결 정보입니다. +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# JWT 서명에 사용할 비밀키입니다. 운영 환경에서는 환경 변수로 충분히 긴 값을 주입해주세요. +jwt.secret=${JWT_SECRET:change-this-secret-key-for-local-development-only} +jwt.access-token-expiration=30m +jwt.refresh-token-expiration=14d + +# Auth 서버가 로그인 검증을 위임할 User 서비스 주소입니다. +user-service.base-url=${USER_SERVICE_BASE_URL:http://localhost:8081} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..7365339 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,6 @@ +spring.datasource.url=jdbc:h2:mem:auth-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.hibernate.ddl-auto=create-drop +spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.RedisAutoConfiguration From 73750c7d50459c9b145486fdbf6edf386b262ec9 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 08:32:40 +0900 Subject: [PATCH 04/16] =?UTF-8?q?delete:=20=EC=93=B8=EB=AA=A8=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/auth/global/config/RedisConfig.java | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/main/java/com/example/auth/global/config/RedisConfig.java diff --git a/src/main/java/com/example/auth/global/config/RedisConfig.java b/src/main/java/com/example/auth/global/config/RedisConfig.java deleted file mode 100644 index 7a20953..0000000 --- a/src/main/java/com/example/auth/global/config/RedisConfig.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.auth.global.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; - -@Configuration -@EnableRedisRepositories -public class RedisConfig { -} From ddcc63aa721f15d239f9f1bbe6594fc842040f1e Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 08:32:53 +0900 Subject: [PATCH 05/16] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/auth/global/config/PasswordEncoderConfig.java | 1 - .../com/example/auth/global/config/SecurityConfig.java | 8 ++++---- .../com/example/auth/global/security/JwtProvider.java | 4 ++-- .../com/example/auth/infra/RefreshTokenRepository.java | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java index d27ffc4..a2c44da 100644 --- a/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java +++ b/src/main/java/com/example/auth/global/config/PasswordEncoderConfig.java @@ -10,7 +10,6 @@ public class PasswordEncoderConfig { @Bean public PasswordEncoder passwordEncoder() { - // 비밀번호는 단방향 해시로 저장해야 하므로 BCrypt 인코더를 공용 Bean으로 등록합니다. return new BCryptPasswordEncoder(); } } diff --git a/src/main/java/com/example/auth/global/config/SecurityConfig.java b/src/main/java/com/example/auth/global/config/SecurityConfig.java index 8a9de36..fcbdea1 100644 --- a/src/main/java/com/example/auth/global/config/SecurityConfig.java +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -21,15 +21,15 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http - // JWT 인증은 세션을 사용하지 않으므로 CSRF와 세션 생성을 끕니다. + // JWT 인증은 세션을 사용하지 않으므로 CSRF와 세션 생성 X .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - // 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둡니다. + // 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둠 .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/signin", "/auth/refresh", "/actuator/health").permitAll() + .requestMatchers("/auth/signin", "/auth/refresh").permitAll() .anyRequest().authenticated() ) - // UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리합니다. + // UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/src/main/java/com/example/auth/global/security/JwtProvider.java b/src/main/java/com/example/auth/global/security/JwtProvider.java index 9624e91..f7aebbd 100644 --- a/src/main/java/com/example/auth/global/security/JwtProvider.java +++ b/src/main/java/com/example/auth/global/security/JwtProvider.java @@ -62,13 +62,13 @@ public Authentication getAuthentication(String token) { return new UsernamePasswordAuthenticationToken(subject, null, authorities); } - // 토큰 서명과 만료 시간을 검증합니다. 문제가 있으면 jjwt 예외가 발생합니다. + // 토큰 서명과 만료 시간을 검증 public boolean validateToken(String token) { parseClaims(token); return true; } - // 로그아웃이나 재발급에서 토큰 주인을 찾을 때 사용합니다. + // 로그아웃이나 재발급에서 토큰 주인을 찾을 때 사용 public String getSubject(String token) { return parseClaims(token).getSubject(); } diff --git a/src/main/java/com/example/auth/infra/RefreshTokenRepository.java b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java index d768ee3..107fc73 100644 --- a/src/main/java/com/example/auth/infra/RefreshTokenRepository.java +++ b/src/main/java/com/example/auth/infra/RefreshTokenRepository.java @@ -16,7 +16,7 @@ public RefreshTokenRepository(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } - // Refresh Token은 서버에도 저장해 재발급과 로그아웃 시 유효성을 확인합니다. + // Refresh Token은 서버에도 저장해 재발급과 로그아웃 시 유효성을 확인 public void save(String subject, String refreshToken, long ttlMillis) { redisTemplate.opsForValue() .set(createKey(subject), refreshToken, Duration.ofMillis(ttlMillis)); From 35b406f1ac7c98dc30b78ac3eb4f86d3781d7834 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 11:19:07 +0900 Subject: [PATCH 06/16] =?UTF-8?q?delete:=20Repository=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/auth/repository/AuthRepository.java | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/main/java/com/example/auth/repository/AuthRepository.java diff --git a/src/main/java/com/example/auth/repository/AuthRepository.java b/src/main/java/com/example/auth/repository/AuthRepository.java deleted file mode 100644 index 3f23b1f..0000000 --- a/src/main/java/com/example/auth/repository/AuthRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.example.auth.repository; - -public class AuthRepository { -} From ac9b30b8f0ae9b074636d62238920687493ac2d8 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 11:20:37 +0900 Subject: [PATCH 07/16] =?UTF-8?q?refactor:=20client=20dto=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/auth/global/client/UserServiceClient.java | 1 + .../auth/global/client/{ => dto}/UserAuthResponse.java | 5 ++--- src/main/java/com/example/auth/service/AuthService.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename src/main/java/com/example/auth/global/client/{ => dto}/UserAuthResponse.java (75%) diff --git a/src/main/java/com/example/auth/global/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java index 7fc03a1..83fa477 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -1,6 +1,7 @@ package com.example.auth.global.client; import com.example.auth.dto.request.SignInRequest; +import com.example.auth.global.client.dto.UserAuthResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; diff --git a/src/main/java/com/example/auth/global/client/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java similarity index 75% rename from src/main/java/com/example/auth/global/client/UserAuthResponse.java rename to src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java index 329d6fd..c90a68f 100644 --- a/src/main/java/com/example/auth/global/client/UserAuthResponse.java +++ b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java @@ -1,4 +1,4 @@ -package com.example.auth.global.client; +package com.example.auth.global.client.dto; import java.util.List; @@ -7,5 +7,4 @@ public record UserAuthResponse( String name, String role, List roles -) { -} +) {} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 2d6a3b1..eccbcb9 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -6,7 +6,7 @@ import com.example.auth.dto.response.RefreshResponse; import com.example.auth.dto.response.SignInResponse; import com.example.auth.global.client.UserServiceClient; -import com.example.auth.global.client.UserAuthResponse; +import com.example.auth.global.client.dto.UserAuthResponse; import com.example.auth.global.security.JwtProvider; import com.example.auth.infra.RefreshTokenRepository; import java.util.List; From 4af467d4eb730c7c5e1d0f31f5dfda8009070900 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 15:12:05 +0900 Subject: [PATCH 08/16] =?UTF-8?q?refactor:=20AuthService=EC=97=90=20?= =?UTF-8?q?=EB=AA=B0=EB=A0=A4=EC=9E=88=EB=8D=98=20=EC=B1=85=EC=9E=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/auth/service/AuthService.java | 165 +++++------------- .../example/auth/service/AuthValidator.java | 35 ++++ .../example/auth/service/RoleProcessor.java | 51 ++++++ .../example/auth/service/TokenService.java | 85 +++++++++ 4 files changed, 218 insertions(+), 118 deletions(-) create mode 100644 src/main/java/com/example/auth/service/AuthValidator.java create mode 100644 src/main/java/com/example/auth/service/RoleProcessor.java create mode 100644 src/main/java/com/example/auth/service/TokenService.java diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index eccbcb9..72f0cca 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -1,167 +1,96 @@ package com.example.auth.service; -import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.SignOutRequest; import com.example.auth.dto.response.RefreshResponse; import com.example.auth.dto.response.SignInResponse; import com.example.auth.global.client.UserServiceClient; import com.example.auth.global.client.dto.UserAuthResponse; -import com.example.auth.global.security.JwtProvider; -import com.example.auth.infra.RefreshTokenRepository; import java.util.List; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; @Service public class AuthService { private final UserServiceClient userServiceClient; - private final JwtProvider jwtProvider; - private final RefreshTokenRepository refreshTokenRepository; + private final AuthValidator authValidator; + private final RoleProcessor roleProcessor; + private final TokenService tokenService; - //의존성 주입 public AuthService( UserServiceClient userServiceClient, - JwtProvider jwtProvider, - RefreshTokenRepository refreshTokenRepository + AuthValidator authValidator, + RoleProcessor roleProcessor, + TokenService tokenService ) { this.userServiceClient = userServiceClient; - this.jwtProvider = jwtProvider; - this.refreshTokenRepository = refreshTokenRepository; + this.authValidator = authValidator; + this.roleProcessor = roleProcessor; + this.tokenService = tokenService; } - //로그인 + // 로그인 public SignInResponse login(SignInRequest request) { - validateLoginRequest(request); - // 사용자 검증은 User 서비스에 맡기고, 검증된 사용자 정보로 토큰을 발급합니다. - UserAuthResponse user = userServiceClient.authenticate(request); - validateAuthenticatedUser(user); + authValidator.validateLoginRequest(request); + + UserAuthResponse user = + userServiceClient.authenticate(request); + + authValidator.validateAuthenticatedUser(user); - String subject = String.valueOf(user.userId()); - List roles = normalizeRoles(user.roles(), user.role()); - TokenPair tokenPair = issueTokens(subject, roles); + List roles = + roleProcessor.normalizeRoles( + user.roles(), + user.role() + ); + + TokenService.TokenPair tokenPair = + tokenService.issueTokens( + String.valueOf(user.userId()), + roles + ); return new SignInResponse( user.name(), - firstRole(roles), + roleProcessor.firstRole(roles), tokenPair.accessToken(), tokenPair.refreshToken(), - String.valueOf(toSeconds(jwtProvider.getAccessTokenExpirationMillis())) + String.valueOf( + tokenService.accessTokenExpiresInSeconds() + ) ); } - //리프레쉬 토큰 + // 토큰 재발급 public RefreshResponse refresh(RefreshRequest request) { - validateRefreshToken(request.refreshToken()); - jwtProvider.validateToken(request.refreshToken()); - String subject = jwtProvider.getSubject(request.refreshToken()); + authValidator.validateRefreshRequest(request); + + String subject = + tokenService.getValidRefreshTokenSubject( + request.refreshToken() + ); - if (!refreshTokenRepository.existsBySubjectAndToken(subject, request.refreshToken())) { - throw new IllegalArgumentException("저장된 Refresh Token과 일치하지 않습니다."); - } + List roles = + tokenService.getRoles(request.refreshToken()); - // Refresh Token이 정상일 때 Access Token과 Refresh Token을 모두 새로 발급합니다. - List roles = jwtProvider.getAuthentication(request.refreshToken()).getAuthorities().stream() - .map(authority -> authority.getAuthority()) - .toList(); + TokenService.TokenPair tokenPair = + tokenService.issueTokens(subject, roles); - TokenPair tokenPair = issueTokens(subject, roles); return new RefreshResponse( tokenPair.accessToken(), tokenPair.refreshToken(), - toSeconds(jwtProvider.getAccessTokenExpirationMillis()) + tokenService.accessTokenExpiresInSeconds() ); } - //로그아웃 + // 로그아웃 public void logout(SignOutRequest request) { - validateSignOutRequest(request); - jwtProvider.validateToken(request.refresh_token()); - String subject = jwtProvider.getSubject(request.refresh_token()); - - // 저장된 Refresh Token을 제거하면 이후 재발급이 불가능해집니다. - refreshTokenRepository.deleteBySubject(subject); - } - - //토큰 발급 - private TokenPair issueTokens(String subject, List roles) { - String accessToken = jwtProvider.createAccessToken(subject, roles); - String refreshToken = jwtProvider.createRefreshToken(subject, roles); - - refreshTokenRepository.save(subject, refreshToken, jwtProvider.getRefreshTokenExpirationMillis()); - - return new TokenPair( - accessToken, - refreshToken - ); - } - - /* - 내부 함수 - */ - private void validateLoginRequest(SignInRequest request) { - if (request == null || !StringUtils.hasText(request.email()) || !StringUtils.hasText(request.password())) { - throw new IllegalArgumentException("아이디와 비밀번호를 입력해주세요."); - } - } - - private void validateRefreshToken(String refreshToken) { - if (!StringUtils.hasText(refreshToken)) { - throw new IllegalArgumentException("Refresh Token을 입력해주세요."); - } - } - - private void validateSignOutRequest(SignOutRequest request) { - if (request == null || !StringUtils.hasText(request.refresh_token())) { - throw new IllegalArgumentException("Refresh Token을 입력해주세요."); - } - } - - private void validateAuthenticatedUser(UserAuthResponse user) { - if (user == null || user.userId() == null) { - throw new IllegalArgumentException("사용자 인증 정보를 확인할 수 없습니다."); - } - } + authValidator.validateSignOutRequest(request); - private List normalizeRoles(List roles, String role) { - if (roles == null || roles.isEmpty()) { - if (StringUtils.hasText(role)) { - return List.of(normalizeRole(role)); - } - return List.of("ROLE_USER"); - } - - List normalizedRoles = roles.stream() - .filter(StringUtils::hasText) - .map(this::normalizeRole) - .distinct() - .toList(); - - if (normalizedRoles.isEmpty()) { - return List.of("ROLE_USER"); - } - - return normalizedRoles; + tokenService.deleteRefreshToken(request.refresh_token()); } - - private String normalizeRole(String role) { - return role.startsWith("ROLE_") ? role : "ROLE_" + role; - } - - private String firstRole(List roles) { - return roles.isEmpty() ? "ROLE_USER" : roles.getFirst(); - } - - private long toSeconds(long millis) { - return millis / 1000; - } - - private record TokenPair( - String accessToken, - String refreshToken - ) {} } diff --git a/src/main/java/com/example/auth/service/AuthValidator.java b/src/main/java/com/example/auth/service/AuthValidator.java new file mode 100644 index 0000000..522adfa --- /dev/null +++ b/src/main/java/com/example/auth/service/AuthValidator.java @@ -0,0 +1,35 @@ +package com.example.auth.service; + +import com.example.auth.dto.request.RefreshRequest; +import com.example.auth.dto.request.SignInRequest; +import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.global.client.dto.UserAuthResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class AuthValidator { + public void validateLoginRequest(SignInRequest request) { + if (request == null || !StringUtils.hasText(request.email()) || !StringUtils.hasText(request.password())) { + throw new IllegalArgumentException("아이디와 비밀번호를 입력해주세요."); + } + } + + public void validateRefreshRequest(RefreshRequest request) { + if (request == null || !StringUtils.hasText(request.refreshToken())) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + public void validateSignOutRequest(SignOutRequest request) { + if (request == null || !StringUtils.hasText(request.refresh_token())) { + throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + } + } + + public void validateAuthenticatedUser(UserAuthResponse user) { + if (user == null || user.userId() == null) { + throw new IllegalArgumentException("사용자 인증 정보를 확인할 수 없습니다."); + } + } +} diff --git a/src/main/java/com/example/auth/service/RoleProcessor.java b/src/main/java/com/example/auth/service/RoleProcessor.java new file mode 100644 index 0000000..229d998 --- /dev/null +++ b/src/main/java/com/example/auth/service/RoleProcessor.java @@ -0,0 +1,51 @@ +package com.example.auth.service; + +import java.util.List; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class RoleProcessor { + + public List normalizeRoles( + List roles, + String role + ) { + + if (roles == null || roles.isEmpty()) { + + if (StringUtils.hasText(role)) { + return List.of(normalizeRole(role)); + } + + return List.of("ROLE_USER"); + } + + List normalizedRoles = + roles.stream() + .filter(StringUtils::hasText) + .map(this::normalizeRole) + .distinct() + .toList(); + + if (normalizedRoles.isEmpty()) { + return List.of("ROLE_USER"); + } + + return normalizedRoles; + } + + public String firstRole(List roles) { + + return roles.isEmpty() + ? "ROLE_USER" + : roles.getFirst(); + } + + private String normalizeRole(String role) { + + return role.startsWith("ROLE_") + ? role + : "ROLE_" + role; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/auth/service/TokenService.java b/src/main/java/com/example/auth/service/TokenService.java new file mode 100644 index 0000000..3785eea --- /dev/null +++ b/src/main/java/com/example/auth/service/TokenService.java @@ -0,0 +1,85 @@ +package com.example.auth.service; + +import com.example.auth.global.security.JwtProvider; +import com.example.auth.infra.RefreshTokenRepository; +import java.util.List; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +public class TokenService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenService( + JwtProvider jwtProvider, + RefreshTokenRepository refreshTokenRepository + ) { + this.jwtProvider = jwtProvider; + this.refreshTokenRepository = refreshTokenRepository; + } + + public TokenPair issueTokens( + String subject, + List roles + ) { + + String accessToken = + jwtProvider.createAccessToken(subject, roles); + + String refreshToken = + jwtProvider.createRefreshToken(subject, roles); + + refreshTokenRepository.save( + subject, + refreshToken, + jwtProvider.getRefreshTokenExpirationMillis() + ); + + return new TokenPair( + accessToken, + refreshToken + ); + } + + public long accessTokenExpiresInSeconds() { + return jwtProvider.getAccessTokenExpirationMillis() / 1000; + } + + public String getValidRefreshTokenSubject(String refreshToken) { + jwtProvider.validateToken(refreshToken); + + String subject = jwtProvider.getSubject(refreshToken); + + if (!refreshTokenRepository.existsBySubjectAndToken(subject, refreshToken)) { + throw new IllegalArgumentException( + "저장된 Refresh Token과 일치하지 않습니다." + ); + } + + return subject; + } + + public List getRoles(String token) { + Authentication authentication = + jwtProvider.getAuthentication(token); + + return authentication.getAuthorities() + .stream() + .map(authority -> authority.getAuthority()) + .toList(); + } + + public void deleteRefreshToken(String refreshToken) { + String subject = + getValidRefreshTokenSubject(refreshToken); + + refreshTokenRepository.deleteBySubject(subject); + } + + public record TokenPair( + String accessToken, + String refreshToken + ) {} +} From f536187477cff23879d249c4c28581e141852aea Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 17:21:05 +0900 Subject: [PATCH 09/16] refactor: application.properties -> yaml --- src/main/resources/application.properties | 24 ----------------------- src/test/resources/application.properties | 6 ------ 2 files changed, 30 deletions(-) delete mode 100644 src/main/resources/application.properties delete mode 100644 src/test/resources/application.properties diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index dd45e62..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,24 +0,0 @@ -spring.application.name=Auth - -# PostgreSQL 연결 정보입니다. Docker Compose에서는 postgres 서비스 이름을 DB_HOST로 사용합니다. -spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:auth_db} -spring.datasource.username=${DB_USERNAME:auth_user} -spring.datasource.password=${DB_PASSWORD:auth_password} -spring.datasource.driver-class-name=org.postgresql.Driver - -# 개발 초기에는 update가 편하고, 운영에서는 validate 또는 none으로 바꾸는 것을 권장합니다. -spring.jpa.hibernate.ddl-auto=${JPA_DDL_AUTO:update} -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect - -# Refresh Token 저장과 로그아웃 처리를 위한 Redis 연결 정보입니다. -spring.data.redis.host=${REDIS_HOST:localhost} -spring.data.redis.port=${REDIS_PORT:6379} -spring.data.redis.password=${REDIS_PASSWORD:} - -# JWT 서명에 사용할 비밀키입니다. 운영 환경에서는 환경 변수로 충분히 긴 값을 주입해주세요. -jwt.secret=${JWT_SECRET:change-this-secret-key-for-local-development-only} -jwt.access-token-expiration=30m -jwt.refresh-token-expiration=14d - -# Auth 서버가 로그인 검증을 위임할 User 서비스 주소입니다. -user-service.base-url=${USER_SERVICE_BASE_URL:http://localhost:8081} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties deleted file mode 100644 index 7365339..0000000 --- a/src/test/resources/application.properties +++ /dev/null @@ -1,6 +0,0 @@ -spring.datasource.url=jdbc:h2:mem:auth-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.username=sa -spring.datasource.password= -spring.jpa.hibernate.ddl-auto=create-drop -spring.autoconfigure.exclude=org.springframework.boot.data.redis.autoconfigure.RedisAutoConfiguration From b82f32ab1828bf760785995f388eb2997e2cc972 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 17:21:56 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat:=20Userid=20->=20UUID=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/auth/controller/AuthController.java | 13 +++++++------ .../auth/global/client/dto/UserAuthResponse.java | 3 ++- .../java/com/example/auth/service/AuthService.java | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java index 5c6c08b..5c1587c 100644 --- a/src/main/java/com/example/auth/controller/AuthController.java +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -8,6 +8,7 @@ import com.example.auth.global.dto.ApiResponse; import com.example.auth.global.util.ResponseUtil; import com.example.auth.service.AuthService; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -25,23 +26,23 @@ public AuthController(AuthService authService) { } @PostMapping("/signin") - public ApiResponse signin(@RequestBody SignInRequest request) { + public ResponseEntity> signin(@RequestBody SignInRequest request) { // 로그인 성공 시 Access Token과 Refresh Token을 클라이언트에게 내려줍니다. SignInResponse response = authService.login(request); - return ResponseUtil.success("로그인에 성공했습니다.", response); + return ResponseEntity.ok(ResponseUtil.success("로그인에 성공했습니다.", response)); } @PostMapping("/refresh") - public ApiResponse refresh(@RequestBody RefreshRequest request) { + public ResponseEntity> refresh(@RequestBody RefreshRequest request) { // Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다. RefreshResponse response = authService.refresh(request); - return ResponseUtil.success("토큰 재발급에 성공했습니다.", response); + return ResponseEntity.ok(ResponseUtil.success("토큰 재발급에 성공했습니다.", response)); } @DeleteMapping("/signout") - public ApiResponse signout(@RequestBody SignOutRequest request) { + public ResponseEntity> signout(@RequestBody SignOutRequest request) { // 로그아웃은 클라이언트가 가진 Refresh Token을 저장소에서 제거하는 방식입니다. authService.logout(request); - return ResponseUtil.success("로그아웃에 성공했습니다."); + return ResponseEntity.ok(ResponseUtil.success("로그아웃에 성공했습니다.")); } } diff --git a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java index c90a68f..aaf7ff4 100644 --- a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java +++ b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java @@ -1,9 +1,10 @@ package com.example.auth.global.client.dto; import java.util.List; +import java.util.UUID; public record UserAuthResponse( - Long userId, + UUID userId, String name, String role, List roles diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 72f0cca..076a6b6 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -48,7 +48,7 @@ public SignInResponse login(SignInRequest request) { TokenService.TokenPair tokenPair = tokenService.issueTokens( - String.valueOf(user.userId()), + user.userId().toString(), roles ); From c7a2f8a782cc5383cb82461528be2693413b9633 Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 17:26:58 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat:=20UserAuthResponse=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=8B=A8=EC=9D=BC=20=EC=97=AD=ED=95=A0=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/client/dto/UserAuthResponse.java | 2 -- src/main/resources/application.yml | 35 +++++++++++++++++++ src/test/resources/application.yml | 13 +++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/application.yml create mode 100644 src/test/resources/application.yml diff --git a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java index aaf7ff4..5e7e8fb 100644 --- a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java +++ b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java @@ -1,11 +1,9 @@ package com.example.auth.global.client.dto; -import java.util.List; import java.util.UUID; public record UserAuthResponse( UUID userId, String name, String role, - List roles ) {} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9f0ffcc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,35 @@ +spring: + application: + name: Auth + + # PostgreSQL 연결 정보입니다. Docker Compose에서는 postgres 서비스 이름을 DB_HOST로 사용합니다. + datasource: + url: "jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:auth_db}" + username: "${DB_USERNAME:auth_user}" + password: "${DB_PASSWORD:auth_password}" + driver-class-name: org.postgresql.Driver + + # 개발 초기에는 update가 편하고, 운영에서는 validate 또는 none으로 바꾸는 것을 권장합니다. + jpa: + hibernate: + ddl-auto: "${JPA_DDL_AUTO:update}" + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + + # Refresh Token 저장과 로그아웃 처리를 위한 Redis 연결 정보입니다. + data: + redis: + host: "${REDIS_HOST:localhost}" + port: "${REDIS_PORT:6379}" + password: "${REDIS_PASSWORD:}" + +# JWT 서명에 사용할 비밀키입니다. 운영 환경에서는 환경 변수로 충분히 긴 값을 주입해주세요. +jwt: + secret: "${JWT_SECRET:change-this-secret-key-for-local-development-only}" + access-token-expiration: 30m + refresh-token-expiration: 14d + +# Auth 서버가 로그인 검증을 위임할 User 서비스 주소입니다. +user-service: + base-url: "${USER_SERVICE_BASE_URL:http://localhost:8081}" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..9c2a10c --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,13 @@ +spring: + datasource: + url: "jdbc:h2:mem:auth-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE" + driver-class-name: org.h2.Driver + username: sa + password: "" + + jpa: + hibernate: + ddl-auto: create-drop + + autoconfigure: + exclude: org.springframework.boot.data.redis.autoconfigure.RedisAutoConfiguration From cf1730c0a0b745bbf26219c6f9b076f4ea9f947e Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 17:51:22 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20=EC=97=AD=ED=95=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20Response=EC=97=90=20from=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/response/RefreshResponse.java | 11 +++++ .../auth/dto/response/SignInResponse.java | 17 +++++++ .../global/client/dto/UserAuthResponse.java | 2 +- .../com/example/auth/service/AuthService.java | 20 ++++----- .../example/auth/service/RoleProcessor.java | 44 ++++--------------- 5 files changed, 46 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/example/auth/dto/response/RefreshResponse.java b/src/main/java/com/example/auth/dto/response/RefreshResponse.java index 0f21fda..6463f97 100644 --- a/src/main/java/com/example/auth/dto/response/RefreshResponse.java +++ b/src/main/java/com/example/auth/dto/response/RefreshResponse.java @@ -5,4 +5,15 @@ public record RefreshResponse( String refresh_token, Number expires_in ) { + public static RefreshResponse from( + String accessToken, + String refreshToken, + long expiresInSeconds + ) { + return new RefreshResponse( + accessToken, + refreshToken, + expiresInSeconds + ); + } } diff --git a/src/main/java/com/example/auth/dto/response/SignInResponse.java b/src/main/java/com/example/auth/dto/response/SignInResponse.java index 72df0d8..0aafeaa 100644 --- a/src/main/java/com/example/auth/dto/response/SignInResponse.java +++ b/src/main/java/com/example/auth/dto/response/SignInResponse.java @@ -1,5 +1,7 @@ package com.example.auth.dto.response; +import com.example.auth.global.client.dto.UserAuthResponse; + public record SignInResponse( String name, String role, @@ -7,4 +9,19 @@ public record SignInResponse( String refresh_token, String expires_in ) { + public static SignInResponse from( + UserAuthResponse user, + String role, + String accessToken, + String refreshToken, + long expiresInSeconds + ) { + return new SignInResponse( + user.name(), + role, + accessToken, + refreshToken, + String.valueOf(expiresInSeconds) + ); + } } diff --git a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java index 5e7e8fb..d6fe1d0 100644 --- a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java +++ b/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java @@ -5,5 +5,5 @@ public record UserAuthResponse( UUID userId, String name, - String role, + String role ) {} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 076a6b6..83643b1 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -40,11 +40,11 @@ public SignInResponse login(SignInRequest request) { authValidator.validateAuthenticatedUser(user); + String role = + roleProcessor.normalizeRole(user.role()); + List roles = - roleProcessor.normalizeRoles( - user.roles(), - user.role() - ); + roleProcessor.toAuthorities(role); TokenService.TokenPair tokenPair = tokenService.issueTokens( @@ -52,14 +52,12 @@ public SignInResponse login(SignInRequest request) { roles ); - return new SignInResponse( - user.name(), - roleProcessor.firstRole(roles), + return SignInResponse.from( + user, + role, tokenPair.accessToken(), tokenPair.refreshToken(), - String.valueOf( - tokenService.accessTokenExpiresInSeconds() - ) + tokenService.accessTokenExpiresInSeconds() ); } @@ -79,7 +77,7 @@ public RefreshResponse refresh(RefreshRequest request) { TokenService.TokenPair tokenPair = tokenService.issueTokens(subject, roles); - return new RefreshResponse( + return RefreshResponse.from( tokenPair.accessToken(), tokenPair.refreshToken(), tokenService.accessTokenExpiresInSeconds() diff --git a/src/main/java/com/example/auth/service/RoleProcessor.java b/src/main/java/com/example/auth/service/RoleProcessor.java index 229d998..c9bc8d4 100644 --- a/src/main/java/com/example/auth/service/RoleProcessor.java +++ b/src/main/java/com/example/auth/service/RoleProcessor.java @@ -7,45 +7,17 @@ @Component public class RoleProcessor { - public List normalizeRoles( - List roles, - String role - ) { - - if (roles == null || roles.isEmpty()) { - - if (StringUtils.hasText(role)) { - return List.of(normalizeRole(role)); - } - - return List.of("ROLE_USER"); + public String normalizeRole(String role) { + if (!StringUtils.hasText(role)) { + return "ROLE_USER"; } - List normalizedRoles = - roles.stream() - .filter(StringUtils::hasText) - .map(this::normalizeRole) - .distinct() - .toList(); - - if (normalizedRoles.isEmpty()) { - return List.of("ROLE_USER"); - } - - return normalizedRoles; - } - - public String firstRole(List roles) { - - return roles.isEmpty() - ? "ROLE_USER" - : roles.getFirst(); - } - - private String normalizeRole(String role) { - return role.startsWith("ROLE_") ? role : "ROLE_" + role; } -} \ No newline at end of file + + public List toAuthorities(String role) { + return List.of(normalizeRole(role)); + } +} From e253cf105ff6d2a484ec2f54880ee69b036eaa8b Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 17:57:28 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat:=20From=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/response/RefreshResponse.java | 11 ----------- .../auth/dto/response/SignInResponse.java | 17 ----------------- .../com/example/auth/service/AuthService.java | 8 ++++---- 3 files changed, 4 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/example/auth/dto/response/RefreshResponse.java b/src/main/java/com/example/auth/dto/response/RefreshResponse.java index 6463f97..0f21fda 100644 --- a/src/main/java/com/example/auth/dto/response/RefreshResponse.java +++ b/src/main/java/com/example/auth/dto/response/RefreshResponse.java @@ -5,15 +5,4 @@ public record RefreshResponse( String refresh_token, Number expires_in ) { - public static RefreshResponse from( - String accessToken, - String refreshToken, - long expiresInSeconds - ) { - return new RefreshResponse( - accessToken, - refreshToken, - expiresInSeconds - ); - } } diff --git a/src/main/java/com/example/auth/dto/response/SignInResponse.java b/src/main/java/com/example/auth/dto/response/SignInResponse.java index 0aafeaa..72df0d8 100644 --- a/src/main/java/com/example/auth/dto/response/SignInResponse.java +++ b/src/main/java/com/example/auth/dto/response/SignInResponse.java @@ -1,7 +1,5 @@ package com.example.auth.dto.response; -import com.example.auth.global.client.dto.UserAuthResponse; - public record SignInResponse( String name, String role, @@ -9,19 +7,4 @@ public record SignInResponse( String refresh_token, String expires_in ) { - public static SignInResponse from( - UserAuthResponse user, - String role, - String accessToken, - String refreshToken, - long expiresInSeconds - ) { - return new SignInResponse( - user.name(), - role, - accessToken, - refreshToken, - String.valueOf(expiresInSeconds) - ); - } } diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 83643b1..a95237e 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -52,12 +52,12 @@ public SignInResponse login(SignInRequest request) { roles ); - return SignInResponse.from( - user, + return new SignInResponse( + user.name(), role, tokenPair.accessToken(), tokenPair.refreshToken(), - tokenService.accessTokenExpiresInSeconds() + String.valueOf(tokenService.accessTokenExpiresInSeconds()) ); } @@ -77,7 +77,7 @@ public RefreshResponse refresh(RefreshRequest request) { TokenService.TokenPair tokenPair = tokenService.issueTokens(subject, roles); - return RefreshResponse.from( + return new RefreshResponse( tokenPair.accessToken(), tokenPair.refreshToken(), tokenService.accessTokenExpiresInSeconds() From 7c7268b15274b0d8661b6d7a7b853efd36c0a9ae Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 20:12:29 +0900 Subject: [PATCH 14/16] =?UTF-8?q?build:=20=EB=A1=9C=EC=BB=AC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20compose=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 ----- Dockerfile | 20 --------------- docker-compose.yml | 63 ---------------------------------------------- 3 files changed, 89 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index f8626a5..0000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.gradle -build -.git -.idea -*.iml -.env diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b4a2309..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM eclipse-temurin:21-jdk-alpine AS builder - -WORKDIR /app - -COPY gradlew settings.gradle build.gradle ./ -COPY gradle ./gradle -RUN ./gradlew dependencies --no-daemon - -COPY src ./src -RUN ./gradlew bootJar -x test --no-daemon - -FROM eclipse-temurin:21-jre-alpine - -WORKDIR /app - -COPY --from=builder /app/build/libs/*.jar app.jar - -EXPOSE 8080 - -ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 1f804bf..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - container_name: auth-postgres - restart: unless-stopped - environment: - POSTGRES_DB: ${DB_NAME} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_PASSWORD: ${DB_PASSWORD} - ports: - - "${DB_PORT}:5432" - volumes: - - auth_postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: auth-redis - restart: unless-stopped - command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} - ports: - - "${REDIS_PORT}:6379" - volumes: - - auth_redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - auth-app: - build: - context: . - dockerfile: Dockerfile - container_name: auth-app - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy - environment: - DB_HOST: postgres - DB_PORT: 5432 - DB_NAME: ${DB_NAME} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} - REDIS_HOST: redis - REDIS_PORT: 6379 - REDIS_PASSWORD: ${REDIS_PASSWORD} - JPA_DDL_AUTO: ${JPA_DDL_AUTO} - JWT_SECRET: ${JWT_SECRET} - USER_SERVICE_BASE_URL: ${USER_SERVICE_BASE_URL} - ports: - - "${AUTH_SERVER_PORT}:8080" - -volumes: - auth_postgres_data: - auth_redis_data: From 8c25a12d06da5629b25a0134f6f4808dea601ebb Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 20:14:35 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat:=20google=20oauth=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 25 +++++++ .../auth/global/client/UserServiceClient.java | 20 +++++- .../dto/{ => response}/UserAuthResponse.java | 2 +- .../auth/global/config/SecurityConfig.java | 7 +- .../example/auth/global/dto/ApiResponse.java | 4 +- .../auth/global/security/JwtProvider.java | 32 ++++----- .../com/example/auth/service/AuthService.java | 66 ++++++++++++++++--- .../example/auth/service/AuthValidator.java | 8 ++- .../example/auth/service/RoleProcessor.java | 4 -- .../example/auth/service/TokenService.java | 18 ++--- src/main/resources/application.yml | 11 ++++ src/test/resources/application.yml | 10 +++ 12 files changed, 160 insertions(+), 47 deletions(-) rename src/main/java/com/example/auth/global/client/dto/{ => response}/UserAuthResponse.java (70%) diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java index 5c1587c..3465d63 100644 --- a/src/main/java/com/example/auth/controller/AuthController.java +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -3,16 +3,21 @@ import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.RefreshRequest; import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.OauthGoogleCallbackResponse; import com.example.auth.dto.response.RefreshResponse; import com.example.auth.dto.response.SignInResponse; import com.example.auth.global.dto.ApiResponse; import com.example.auth.global.util.ResponseUtil; import com.example.auth.service.AuthService; +import java.net.URI; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -32,6 +37,26 @@ public ResponseEntity> signin(@RequestBody SignInReq return ResponseEntity.ok(ResponseUtil.success("로그인에 성공했습니다.", response)); } + @GetMapping("/oauth/google") + public ResponseEntity googleOauth(@RequestParam(required = false) String state) { + // Google 로그인 페이지로 302 Redirect합니다. + URI redirectUri = authService.createGoogleAuthorizationUri(state); + return ResponseEntity.status(HttpStatus.FOUND) + .location(redirectUri) + .build(); + } + + @GetMapping("/oauth/google/callback") + public ResponseEntity> googleOauthCallback( + @RequestParam String code, + @RequestParam(required = false) String state + ) { + // Google이 내려준 code로 사용자 정보를 확인한 뒤 JWT를 발급합니다. + OauthGoogleCallbackResponse response = + authService.loginWithGoogle(code, state); + return ResponseEntity.ok(ResponseUtil.success("Google 로그인에 성공했습니다.", response)); + } + @PostMapping("/refresh") public ResponseEntity> refresh(@RequestBody RefreshRequest request) { // Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다. diff --git a/src/main/java/com/example/auth/global/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java index 83fa477..52b5cfa 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -1,7 +1,9 @@ package com.example.auth.global.client; import com.example.auth.dto.request.SignInRequest; -import com.example.auth.global.client.dto.UserAuthResponse; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import com.example.auth.global.client.dto.response.UserAuthResponse; +import com.example.auth.global.client.dto.request.UserGoogleOauthRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -26,4 +28,20 @@ public UserAuthResponse authenticate(SignInRequest request) { .retrieve() .body(UserAuthResponse.class); } + + public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { + UserGoogleOauthRequest userRequest = new UserGoogleOauthRequest( + request.providerId(), + request.email(), + request.emailVerified(), + request.name(), + request.picture() + ); + + return restClient.post() + .uri("/internal/users/oauth/google") + .body(userRequest) + .retrieve() + .body(UserAuthResponse.class); + } } diff --git a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java b/src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java similarity index 70% rename from src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java rename to src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java index d6fe1d0..e358f81 100644 --- a/src/main/java/com/example/auth/global/client/dto/UserAuthResponse.java +++ b/src/main/java/com/example/auth/global/client/dto/response/UserAuthResponse.java @@ -1,4 +1,4 @@ -package com.example.auth.global.client.dto; +package com.example.auth.global.client.dto.response; import java.util.UUID; diff --git a/src/main/java/com/example/auth/global/config/SecurityConfig.java b/src/main/java/com/example/auth/global/config/SecurityConfig.java index fcbdea1..9edd70e 100644 --- a/src/main/java/com/example/auth/global/config/SecurityConfig.java +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -26,7 +26,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 로그인과 토큰 재발급 API는 인증 없이 접근할 수 있게 열어둠 .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/signin", "/auth/refresh").permitAll() + .requestMatchers( + "/auth/signin", + "/auth/refresh", + "/auth/oauth/google", + "/auth/oauth/google/callback" + ).permitAll() .anyRequest().authenticated() ) // UsernamePasswordAuthenticationFilter 전에 JWT 필터를 실행해 요청 인증을 먼저 처리 diff --git a/src/main/java/com/example/auth/global/dto/ApiResponse.java b/src/main/java/com/example/auth/global/dto/ApiResponse.java index 8e7cd68..490f6ee 100644 --- a/src/main/java/com/example/auth/global/dto/ApiResponse.java +++ b/src/main/java/com/example/auth/global/dto/ApiResponse.java @@ -2,5 +2,5 @@ public record ApiResponse ( String message, - T date -){} \ No newline at end of file + T data +){} diff --git a/src/main/java/com/example/auth/global/security/JwtProvider.java b/src/main/java/com/example/auth/global/security/JwtProvider.java index f7aebbd..5043067 100644 --- a/src/main/java/com/example/auth/global/security/JwtProvider.java +++ b/src/main/java/com/example/auth/global/security/JwtProvider.java @@ -6,9 +6,7 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Duration; -import java.util.Collection; import java.util.Date; -import java.util.List; import javax.crypto.SecretKey; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -33,12 +31,12 @@ public JwtProvider( this.refreshTokenExpirationMillis = refreshTokenExpiration.toMillis(); } - public String createAccessToken(String subject, Collection roles) { - return createToken(subject, roles, accessTokenExpirationMillis); + public String createAccessToken(String subject, String role) { + return createToken(subject, role, accessTokenExpirationMillis); } - public String createRefreshToken(String subject, Collection roles) { - return createToken(subject, roles, refreshTokenExpirationMillis); + public String createRefreshToken(String subject, String role) { + return createToken(subject, role, refreshTokenExpirationMillis); } public long getAccessTokenExpirationMillis() { @@ -49,17 +47,17 @@ public long getRefreshTokenExpirationMillis() { return refreshTokenExpirationMillis; } - // JWT 안의 subject와 roles를 Spring Security가 이해하는 Authentication 객체로 바꿉니다. + // JWT 안의 subject와 role을 Spring Security가 이해하는 Authentication 객체로 바꿉니다. public Authentication getAuthentication(String token) { Claims claims = parseClaims(token); String subject = claims.getSubject(); - List roles = claims.get("roles", List.class); + String role = claims.get("role", String.class); - List authorities = roles.stream() - .map(SimpleGrantedAuthority::new) - .toList(); - - return new UsernamePasswordAuthenticationToken(subject, null, authorities); + return new UsernamePasswordAuthenticationToken( + subject, + null, + java.util.List.of(new SimpleGrantedAuthority(role)) + ); } // 토큰 서명과 만료 시간을 검증 @@ -73,13 +71,17 @@ public String getSubject(String token) { return parseClaims(token).getSubject(); } - private String createToken(String subject, Collection roles, long expirationMillis) { + public String getRole(String token) { + return parseClaims(token).get("role", String.class); + } + + private String createToken(String subject, String role, long expirationMillis) { Date now = new Date(); Date expiration = new Date(now.getTime() + expirationMillis); return Jwts.builder() .subject(subject) - .claim("roles", roles) + .claim("role", role) .issuedAt(now) .expiration(expiration) .signWith(secretKey) diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index a95237e..55b8f01 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -3,27 +3,35 @@ import com.example.auth.dto.request.RefreshRequest; import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.SignOutRequest; +import com.example.auth.dto.response.OauthGoogleCallbackResponse; import com.example.auth.dto.response.RefreshResponse; import com.example.auth.dto.response.SignInResponse; +import com.example.auth.global.client.GoogleOauthClient; import com.example.auth.global.client.UserServiceClient; -import com.example.auth.global.client.dto.UserAuthResponse; -import java.util.List; +import com.example.auth.global.client.dto.response.GoogleTokenResponse; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import com.example.auth.global.client.dto.response.UserAuthResponse; +import java.net.URI; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; @Service public class AuthService { + private final GoogleOauthClient googleOauthClient; private final UserServiceClient userServiceClient; private final AuthValidator authValidator; private final RoleProcessor roleProcessor; private final TokenService tokenService; public AuthService( + GoogleOauthClient googleOauthClient, UserServiceClient userServiceClient, AuthValidator authValidator, RoleProcessor roleProcessor, TokenService tokenService ) { + this.googleOauthClient = googleOauthClient; this.userServiceClient = userServiceClient; this.authValidator = authValidator; this.roleProcessor = roleProcessor; @@ -43,13 +51,10 @@ public SignInResponse login(SignInRequest request) { String role = roleProcessor.normalizeRole(user.role()); - List roles = - roleProcessor.toAuthorities(role); - TokenService.TokenPair tokenPair = tokenService.issueTokens( user.userId().toString(), - roles + role ); return new SignInResponse( @@ -61,6 +66,49 @@ public SignInResponse login(SignInRequest request) { ); } + // Google OAuth 로그인 페이지로 이동할 URL 생성 + public URI createGoogleAuthorizationUri(String state) { + return googleOauthClient.createAuthorizationUri(state); + } + + // Google OAuth callback code로 사용자 인증 후 JWT 발급 + public OauthGoogleCallbackResponse loginWithGoogle(String code, String state) { + + authValidator.validateGoogleAuthorizationCode(code); + + GoogleTokenResponse googleToken = + googleOauthClient.requestToken(code); + + if (googleToken == null || !StringUtils.hasText(googleToken.accessToken())) { + throw new IllegalArgumentException("Google Access Token을 발급받을 수 없습니다."); + } + + GoogleUserInfoResponse googleUser = + googleOauthClient.requestUserInfo(googleToken.accessToken()); + + UserAuthResponse user = + userServiceClient.authenticateGoogle(googleUser); + + authValidator.validateAuthenticatedUser(user); + + String role = + roleProcessor.normalizeRole(user.role()); + + TokenService.TokenPair tokenPair = + tokenService.issueTokens( + user.userId().toString(), + role + ); + + return new OauthGoogleCallbackResponse( + user.name(), + role, + tokenPair.accessToken(), + tokenPair.refreshToken(), + tokenService.accessTokenExpiresInSeconds() + ); + } + // 토큰 재발급 public RefreshResponse refresh(RefreshRequest request) { @@ -71,11 +119,11 @@ public RefreshResponse refresh(RefreshRequest request) { request.refreshToken() ); - List roles = - tokenService.getRoles(request.refreshToken()); + String role = + tokenService.getRole(request.refreshToken()); TokenService.TokenPair tokenPair = - tokenService.issueTokens(subject, roles); + tokenService.issueTokens(subject, role); return new RefreshResponse( tokenPair.accessToken(), diff --git a/src/main/java/com/example/auth/service/AuthValidator.java b/src/main/java/com/example/auth/service/AuthValidator.java index 522adfa..122ac9e 100644 --- a/src/main/java/com/example/auth/service/AuthValidator.java +++ b/src/main/java/com/example/auth/service/AuthValidator.java @@ -3,7 +3,7 @@ import com.example.auth.dto.request.RefreshRequest; import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.SignOutRequest; -import com.example.auth.global.client.dto.UserAuthResponse; +import com.example.auth.global.client.dto.response.UserAuthResponse; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -21,6 +21,12 @@ public void validateRefreshRequest(RefreshRequest request) { } } + public void validateGoogleAuthorizationCode(String code) { + if (!StringUtils.hasText(code)) { + throw new IllegalArgumentException("Google Authorization Code를 입력해주세요."); + } + } + public void validateSignOutRequest(SignOutRequest request) { if (request == null || !StringUtils.hasText(request.refresh_token())) { throw new IllegalArgumentException("Refresh Token을 입력해주세요."); diff --git a/src/main/java/com/example/auth/service/RoleProcessor.java b/src/main/java/com/example/auth/service/RoleProcessor.java index c9bc8d4..26b6b30 100644 --- a/src/main/java/com/example/auth/service/RoleProcessor.java +++ b/src/main/java/com/example/auth/service/RoleProcessor.java @@ -1,6 +1,5 @@ package com.example.auth.service; -import java.util.List; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -17,7 +16,4 @@ public String normalizeRole(String role) { : "ROLE_" + role; } - public List toAuthorities(String role) { - return List.of(normalizeRole(role)); - } } diff --git a/src/main/java/com/example/auth/service/TokenService.java b/src/main/java/com/example/auth/service/TokenService.java index 3785eea..a5ef24e 100644 --- a/src/main/java/com/example/auth/service/TokenService.java +++ b/src/main/java/com/example/auth/service/TokenService.java @@ -2,8 +2,6 @@ import com.example.auth.global.security.JwtProvider; import com.example.auth.infra.RefreshTokenRepository; -import java.util.List; -import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; @Service @@ -22,14 +20,14 @@ public TokenService( public TokenPair issueTokens( String subject, - List roles + String role ) { String accessToken = - jwtProvider.createAccessToken(subject, roles); + jwtProvider.createAccessToken(subject, role); String refreshToken = - jwtProvider.createRefreshToken(subject, roles); + jwtProvider.createRefreshToken(subject, role); refreshTokenRepository.save( subject, @@ -61,14 +59,8 @@ public String getValidRefreshTokenSubject(String refreshToken) { return subject; } - public List getRoles(String token) { - Authentication authentication = - jwtProvider.getAuthentication(token); - - return authentication.getAuthorities() - .stream() - .map(authority -> authority.getAuthority()) - .toList(); + public String getRole(String token) { + return jwtProvider.getRole(token); } public void deleteRefreshToken(String refreshToken) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9f0ffcc..b177965 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,3 +33,14 @@ jwt: # Auth 서버가 로그인 검증을 위임할 User 서비스 주소입니다. user-service: base-url: "${USER_SERVICE_BASE_URL:http://localhost:8081}" + +# Google OAuth 로그인 설정입니다. +oauth: + google: + client-id: "${GOOGLE_CLIENT_ID:}" + client-secret: "${GOOGLE_CLIENT_SECRET:}" + redirect-uri: "${GOOGLE_REDIRECT_URI:http://localhost:8080/auth/oauth/google/callback}" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" + scope: "openid email profile" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 9c2a10c..c89bf67 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -11,3 +11,13 @@ spring: autoconfigure: exclude: org.springframework.boot.data.redis.autoconfigure.RedisAutoConfiguration + +oauth: + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + redirect-uri: "http://localhost:8080/auth/oauth/google/callback" + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://oauth2.googleapis.com/token" + user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" + scope: "openid email profile" From b77cb53737748aa2066d498bcd962f239012d9ac Mon Sep 17 00:00:00 2001 From: hyun731 Date: Thu, 7 May 2026 20:14:43 +0900 Subject: [PATCH 16/16] =?UTF-8?q?feat:=20google=20oauth=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/global/client/GoogleOauthClient.java | 85 +++++++++++++++++++ .../dto/request/UserGoogleOauthRequest.java | 10 +++ .../dto/response/GoogleTokenResponse.java | 21 +++++ .../dto/response/GoogleUserInfoResponse.java | 18 ++++ 4 files changed, 134 insertions(+) create mode 100644 src/main/java/com/example/auth/global/client/GoogleOauthClient.java create mode 100644 src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java diff --git a/src/main/java/com/example/auth/global/client/GoogleOauthClient.java b/src/main/java/com/example/auth/global/client/GoogleOauthClient.java new file mode 100644 index 0000000..0bd40ca --- /dev/null +++ b/src/main/java/com/example/auth/global/client/GoogleOauthClient.java @@ -0,0 +1,85 @@ +package com.example.auth.global.client; + +import com.example.auth.global.client.dto.response.GoogleTokenResponse; +import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class GoogleOauthClient { + + private final RestClient restClient; + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final String authorizationUri; + private final String tokenUri; + private final String userInfoUri; + private final String scope; + + public GoogleOauthClient( + @Value("${oauth.google.client-id}") String clientId, + @Value("${oauth.google.client-secret}") String clientSecret, + @Value("${oauth.google.redirect-uri}") String redirectUri, + @Value("${oauth.google.authorization-uri}") String authorizationUri, + @Value("${oauth.google.token-uri}") String tokenUri, + @Value("${oauth.google.user-info-uri}") String userInfoUri, + @Value("${oauth.google.scope}") String scope + ) { + this.restClient = RestClient.create(); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.userInfoUri = userInfoUri; + this.scope = scope; + } + + public URI createAuthorizationUri(String state) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(authorizationUri) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", scope); + + if (StringUtils.hasText(state)) { + builder.queryParam("state", state); + } + + return builder.build() + .encode() + .toUri(); + } + + public GoogleTokenResponse requestToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("grant_type", "authorization_code"); + + return restClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(GoogleTokenResponse.class); + } + + public GoogleUserInfoResponse requestUserInfo(String accessToken) { + return restClient.get() + .uri(userInfoUri) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .body(GoogleUserInfoResponse.class); + } +} diff --git a/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java b/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java new file mode 100644 index 0000000..b7df6c2 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java @@ -0,0 +1,10 @@ +package com.example.auth.global.client.dto.request; + +public record UserGoogleOauthRequest( + String providerId, + String email, + Boolean emailVerified, + String name, + String picture +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java b/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java new file mode 100644 index 0000000..f5fc5cb --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/GoogleTokenResponse.java @@ -0,0 +1,21 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("expires_in") + Long expiresIn, + + @JsonProperty("scope") + String scope, + + @JsonProperty("id_token") + String idToken +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java b/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java new file mode 100644 index 0000000..f5c07bf --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/GoogleUserInfoResponse.java @@ -0,0 +1,18 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleUserInfoResponse( + @JsonProperty("sub") + String providerId, + + String email, + + @JsonProperty("email_verified") + Boolean emailVerified, + + String name, + + String picture +) { +}