From aa37f0fca2f1d4b06d7f628f8a8413754bd02271 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=ED=99=98?= Date: Sun, 14 Jun 2026 20:13:55 +0900 Subject: [PATCH 1/5] feat: GitHub Actions CI/CD --- .github/workflows/deploy.yml | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9b0e8cb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy Auth to ECS + +on: + push: + branches: + - main + workflow_dispatch: + +env: + AWS_REGION: ap-northeast-2 + AWS_ACCOUNT_ID: 727452759104 + ECR_REPOSITORY: momentlit/auth + ECS_CLUSTER: default + ECS_SERVICE: momentlit-auth-service + IMAGE_TAG: latest + +jobs: + deploy: + name: Build and Deploy Auth + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5 + with: + aws-region: ${{ env.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + run: | + IMAGE_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}:${IMAGE_TAG} + + docker buildx build \ + --platform linux/amd64 \ + --provenance=false \ + -t $IMAGE_URI \ + . \ + --push + + - name: Force new ECS deployment + run: | + aws ecs update-service \ + --cluster $ECS_CLUSTER \ + --service $ECS_SERVICE \ + --force-new-deployment \ + --region $AWS_REGION + + - name: Wait for ECS service stable + run: | + aws ecs wait services-stable \ + --cluster $ECS_CLUSTER \ + --services $ECS_SERVICE \ + --region $AWS_REGION \ No newline at end of file From bfe4607769d6c5d0d642f9c01fa11a7b5d501c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=ED=99=98?= Date: Thu, 18 Jun 2026 14:11:23 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20custom=20exception=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 --- API_SPEC.yaml | 52 ++++++++++++++++++- docs/service-overview.md | 3 +- docs/service-policy.md | 10 +++- .../example/auth/global/dto/ApiResponse.java | 6 ++- .../auth/global/exception/AuthException.java | 7 +++ .../global/exception/BadRequestException.java | 7 +++ .../exception/GlobalExceptionHandler.java | 47 +++++++++++++++++ .../exception/GoogleOauthException.java | 7 +++ .../exception/TokenNotFoundException.java | 7 +++ .../exception/UnauthorizedException.java | 7 +++ .../com/example/auth/service/AuthService.java | 3 +- .../example/auth/service/AuthValidator.java | 12 +++-- .../example/auth/service/TokenService.java | 11 +++- 13 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/example/auth/global/exception/AuthException.java create mode 100644 src/main/java/com/example/auth/global/exception/BadRequestException.java create mode 100644 src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/example/auth/global/exception/GoogleOauthException.java create mode 100644 src/main/java/com/example/auth/global/exception/TokenNotFoundException.java create mode 100644 src/main/java/com/example/auth/global/exception/UnauthorizedException.java diff --git a/API_SPEC.yaml b/API_SPEC.yaml index 6194ffa..69f5c01 100644 --- a/API_SPEC.yaml +++ b/API_SPEC.yaml @@ -9,6 +9,54 @@ servers: tags: [] paths: {} components: - schemas: {} - responses: {} + schemas: + ErrorResponse: + type: object + properties: + message: + type: string + example: "[ERROR: Request/BadRequest] Refresh Token을 입력해주세요." + data: + nullable: true + example: null + required: + - message + - data + responses: + BadRequestError: + description: "Invalid request input." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: Request/BadRequest] Refresh Token을 입력해주세요." + data: null + UnauthorizedError: + description: "Authentication failed or token is invalid." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: Auth/Unauthorized] 유효하지 않은 Refresh Token입니다." + data: null + GoogleOauthError: + description: "Google OAuth request failed." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: Auth/Oauth/Google] Google Access Token을 발급받을 수 없습니다." + data: null + InternalServerError: + description: "Unexpected server error." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: ?/?] 서버 내부 오류가 발생했습니다." + data: null securitySchemes: {} diff --git a/docs/service-overview.md b/docs/service-overview.md index bd422e6..62118c0 100644 --- a/docs/service-overview.md +++ b/docs/service-overview.md @@ -43,7 +43,8 @@ No repository or persistence component is visible. Build dependencies include Sp ## Exception Handling -No project-specific exception handling is visible. +Project-specific exception handling is implemented under `com.example.auth.global.exception`. +Auth-specific exceptions extend `AuthException` and are handled by `GlobalExceptionHandler`. ## Test Structure diff --git a/docs/service-policy.md b/docs/service-policy.md index f5414a8..f7dc616 100644 --- a/docs/service-policy.md +++ b/docs/service-policy.md @@ -31,7 +31,15 @@ State transitions are documented only where visible in entity or service methods ## Exception Cases -No project-specific exception handling is visible. HTTP status mapping for these exceptions is Needs confirmation unless explicitly handled in code. +Project-specific exception handling is visible under `com.example.auth.global.exception`. +Visible mappings: + +- `BadRequestException`: `400 Bad Request` +- `UnauthorizedException`: `401 Unauthorized` +- `TokenNotFoundException`: `401 Unauthorized` +- `GoogleOauthException`: `502 Bad Gateway` +- Other `AuthException`: `500 Internal Server Error` +- Other `Exception`: `500 Internal Server Error` ## API Behavior Policy 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 490f6ee..7e0727a 100644 --- a/src/main/java/com/example/auth/global/dto/ApiResponse.java +++ b/src/main/java/com/example/auth/global/dto/ApiResponse.java @@ -3,4 +3,8 @@ public record ApiResponse ( String message, T data -){} +){ + public static ApiResponse fail(String message) { + return new ApiResponse<>(message, null); + } +} diff --git a/src/main/java/com/example/auth/global/exception/AuthException.java b/src/main/java/com/example/auth/global/exception/AuthException.java new file mode 100644 index 0000000..da98ca1 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/AuthException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class AuthException extends RuntimeException { + public AuthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/global/exception/BadRequestException.java b/src/main/java/com/example/auth/global/exception/BadRequestException.java new file mode 100644 index 0000000..2a765f1 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class BadRequestException extends AuthException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d78213b --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,47 @@ +package com.example.auth.global.exception; + +import com.example.auth.global.dto.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(TokenNotFoundException.class) + public ResponseEntity> tokenNotFoundHandleException(TokenNotFoundException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail("[ERROR: Auth/Token/NotFound] " + e.getMessage())); + } + + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity> unauthorizedHandleException(UnauthorizedException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail("[ERROR: Auth/Unauthorized] " + e.getMessage())); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity> badRequestHandleException(BadRequestException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.fail("[ERROR: Request/BadRequest] " + e.getMessage())); + } + + @ExceptionHandler(GoogleOauthException.class) + public ResponseEntity> googleOauthHandleException(GoogleOauthException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(ApiResponse.fail("[ERROR: Auth/Oauth/Google] " + e.getMessage())); + } + + @ExceptionHandler(AuthException.class) + public ResponseEntity> authHandleException(AuthException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail("[ERROR: Auth/?] " + e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> globalHandleException(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail("[ERROR: ?/?] 서버 내부 오류가 발생했습니다.")); + } +} diff --git a/src/main/java/com/example/auth/global/exception/GoogleOauthException.java b/src/main/java/com/example/auth/global/exception/GoogleOauthException.java new file mode 100644 index 0000000..24cbb0c --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/GoogleOauthException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class GoogleOauthException extends AuthException { + public GoogleOauthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/global/exception/TokenNotFoundException.java b/src/main/java/com/example/auth/global/exception/TokenNotFoundException.java new file mode 100644 index 0000000..6b3e42f --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/TokenNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class TokenNotFoundException extends AuthException { + public TokenNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/global/exception/UnauthorizedException.java b/src/main/java/com/example/auth/global/exception/UnauthorizedException.java new file mode 100644 index 0000000..b4561c7 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class UnauthorizedException extends AuthException { + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index 55b8f01..b62e7c9 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -11,6 +11,7 @@ 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 com.example.auth.global.exception.GoogleOauthException; import java.net.URI; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -80,7 +81,7 @@ public OauthGoogleCallbackResponse loginWithGoogle(String code, String state) { googleOauthClient.requestToken(code); if (googleToken == null || !StringUtils.hasText(googleToken.accessToken())) { - throw new IllegalArgumentException("Google Access Token을 발급받을 수 없습니다."); + throw new GoogleOauthException("Google Access Token을 발급받을 수 없습니다."); } GoogleUserInfoResponse googleUser = diff --git a/src/main/java/com/example/auth/service/AuthValidator.java b/src/main/java/com/example/auth/service/AuthValidator.java index 122ac9e..3ffe492 100644 --- a/src/main/java/com/example/auth/service/AuthValidator.java +++ b/src/main/java/com/example/auth/service/AuthValidator.java @@ -4,6 +4,8 @@ import com.example.auth.dto.request.SignInRequest; import com.example.auth.dto.request.SignOutRequest; import com.example.auth.global.client.dto.response.UserAuthResponse; +import com.example.auth.global.exception.BadRequestException; +import com.example.auth.global.exception.UnauthorizedException; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -11,31 +13,31 @@ public class AuthValidator { public void validateLoginRequest(SignInRequest request) { if (request == null || !StringUtils.hasText(request.email()) || !StringUtils.hasText(request.password())) { - throw new IllegalArgumentException("아이디와 비밀번호를 입력해주세요."); + throw new BadRequestException("아이디와 비밀번호를 입력해주세요."); } } public void validateRefreshRequest(RefreshRequest request) { if (request == null || !StringUtils.hasText(request.refreshToken())) { - throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + throw new BadRequestException("Refresh Token을 입력해주세요."); } } public void validateGoogleAuthorizationCode(String code) { if (!StringUtils.hasText(code)) { - throw new IllegalArgumentException("Google Authorization Code를 입력해주세요."); + throw new BadRequestException("Google Authorization Code를 입력해주세요."); } } public void validateSignOutRequest(SignOutRequest request) { if (request == null || !StringUtils.hasText(request.refresh_token())) { - throw new IllegalArgumentException("Refresh Token을 입력해주세요."); + throw new BadRequestException("Refresh Token을 입력해주세요."); } } public void validateAuthenticatedUser(UserAuthResponse user) { if (user == null || user.userId() == null) { - throw new IllegalArgumentException("사용자 인증 정보를 확인할 수 없습니다."); + throw new UnauthorizedException("사용자 인증 정보를 확인할 수 없습니다."); } } } diff --git a/src/main/java/com/example/auth/service/TokenService.java b/src/main/java/com/example/auth/service/TokenService.java index a5ef24e..148dbd7 100644 --- a/src/main/java/com/example/auth/service/TokenService.java +++ b/src/main/java/com/example/auth/service/TokenService.java @@ -1,7 +1,10 @@ package com.example.auth.service; import com.example.auth.global.security.JwtProvider; +import com.example.auth.global.exception.TokenNotFoundException; +import com.example.auth.global.exception.UnauthorizedException; import com.example.auth.infra.RefreshTokenRepository; +import io.jsonwebtoken.JwtException; import org.springframework.stereotype.Service; @Service @@ -46,12 +49,16 @@ public long accessTokenExpiresInSeconds() { } public String getValidRefreshTokenSubject(String refreshToken) { - jwtProvider.validateToken(refreshToken); + try { + jwtProvider.validateToken(refreshToken); + } catch (JwtException | IllegalArgumentException exception) { + throw new UnauthorizedException("유효하지 않은 Refresh Token입니다."); + } String subject = jwtProvider.getSubject(refreshToken); if (!refreshTokenRepository.existsBySubjectAndToken(subject, refreshToken)) { - throw new IllegalArgumentException( + throw new TokenNotFoundException( "저장된 Refresh Token과 일치하지 않습니다." ); } From 8d06a748d01fde8df2f21a9c0ca3da150cd4d890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=ED=99=98?= Date: Fri, 3 Jul 2026 10:40:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20exception=20=EB=8D=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/global/client/UserServiceClient.java | 17 ++++++++++++----- .../exception/GlobalExceptionHandler.java | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) 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 ef851c8..7563308 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -4,8 +4,10 @@ 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 com.example.auth.global.exception.UnauthorizedException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; @Component @@ -22,11 +24,16 @@ public UserServiceClient( } public UserAuthResponse authenticate(SignInRequest request) { - return restClient.post() - .uri("/internal/users/authenticate") - .body(request) - .retrieve() - .body(UserAuthResponse.class); + try { + return restClient.post() + .uri("/internal/users/authenticate") + .body(request) + .retrieve() + .body(UserAuthResponse.class); + + } catch (HttpClientErrorException.Unauthorized e) { + throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다."); + } } public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { diff --git a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java index d78213b..349483b 100644 --- a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java @@ -1,46 +1,60 @@ package com.example.auth.global.exception; import com.example.auth.global.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(TokenNotFoundException.class) public ResponseEntity> tokenNotFoundHandleException(TokenNotFoundException e) { + log.warn("TokenNotFoundException: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResponse.fail("[ERROR: Auth/Token/NotFound] " + e.getMessage())); } @ExceptionHandler(UnauthorizedException.class) public ResponseEntity> unauthorizedHandleException(UnauthorizedException e) { + log.warn("UnauthorizedException: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResponse.fail("[ERROR: Auth/Unauthorized] " + e.getMessage())); } @ExceptionHandler(BadRequestException.class) public ResponseEntity> badRequestHandleException(BadRequestException e) { + log.warn("BadRequestException: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(ApiResponse.fail("[ERROR: Request/BadRequest] " + e.getMessage())); } @ExceptionHandler(GoogleOauthException.class) public ResponseEntity> googleOauthHandleException(GoogleOauthException e) { + log.error("GoogleOauthException", e); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) .body(ApiResponse.fail("[ERROR: Auth/Oauth/Google] " + e.getMessage())); } @ExceptionHandler(AuthException.class) public ResponseEntity> authHandleException(AuthException e) { + log.error("AuthException", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.fail("[ERROR: Auth/?] " + e.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity> globalHandleException(Exception e) { + log.error("Unhandled exception", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(ApiResponse.fail("[ERROR: ?/?] 서버 내부 오류가 발생했습니다.")); } From 02bb3b531a2303e4d9a59b310f61f7dc4f521d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=ED=99=98?= Date: Wed, 8 Jul 2026 20:51:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20User=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=EA=B0=80=20Auth=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AF=B8=EC=B2=98=EB=A6=AC=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/global/client/UserServiceClient.java | 102 ++++++++++++++++-- .../exception/DownstreamServiceException.java | 24 +++++ .../exception/GlobalExceptionHandler.java | 40 +++++++ 3 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/auth/global/exception/DownstreamServiceException.java 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 7563308..b3a4ff0 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -1,22 +1,33 @@ package com.example.auth.global.client; import com.example.auth.dto.request.SignInRequest; +import com.example.auth.global.client.dto.request.UserGoogleOauthRequest; 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 com.example.auth.global.exception.DownstreamServiceException; import com.example.auth.global.exception.UnauthorizedException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Component; -import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +@Slf4j @Component public class UserServiceClient { + private static final String SERVICE_NAME = "USER"; + private final RestClient restClient; + private final ObjectMapper objectMapper = new ObjectMapper(); public UserServiceClient( - @Value("${user-service.base-url:http://localhost:8081}") String userServiceBaseUrl //임시 + @Value("${user-service.base-url:http://localhost:8081}") String userServiceBaseUrl ) { this.restClient = RestClient.builder() .baseUrl(userServiceBaseUrl) @@ -31,8 +42,15 @@ public UserAuthResponse authenticate(SignInRequest request) { .retrieve() .body(UserAuthResponse.class); - } catch (HttpClientErrorException.Unauthorized e) { - throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다."); + } catch (RestClientResponseException e) { + if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다."); + } + + throw convertToDownstreamException(e); + + } catch (RestClientException e) { + throw convertToConnectionException(e); } } @@ -45,10 +63,72 @@ public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { request.imageUrl() ); - return restClient.post() - .uri("/internal/users/oauth/google") - .body(userRequest) - .retrieve() - .body(UserAuthResponse.class); + try { + return restClient.post() + .uri("/internal/users/oauth/google") + .body(userRequest) + .retrieve() + .body(UserAuthResponse.class); + + } catch (RestClientResponseException e) { + throw convertToDownstreamException(e); + + } catch (RestClientException e) { + throw convertToConnectionException(e); + } + } + + private DownstreamServiceException convertToDownstreamException(RestClientResponseException e) { + String responseBody = e.getResponseBodyAsString(); + String message = extractMessage(responseBody); + HttpStatusCode statusCode = e.getStatusCode(); + + if (statusCode.is4xxClientError()) { + log.warn( + "{} service client error. status={}, body={}", + SERVICE_NAME, + statusCode, + responseBody + ); + } else { + log.error( + "{} service server error. status={}, body={}", + SERVICE_NAME, + statusCode, + responseBody + ); + } + + return new DownstreamServiceException( + SERVICE_NAME, + statusCode, + message, + responseBody + ); + } + + private DownstreamServiceException convertToConnectionException(RestClientException e) { + log.error("{} service connection failed", SERVICE_NAME, e); + + return new DownstreamServiceException( + SERVICE_NAME, + HttpStatus.SERVICE_UNAVAILABLE, + "USER 서비스에 연결할 수 없습니다.", + null + ); + } + + private String extractMessage(String responseBody) { + try { + JsonNode jsonNode = objectMapper.readTree(responseBody); + + if (jsonNode.has("message")) { + return jsonNode.get("message").asText(); + } + + return "USER 서비스 호출 중 오류가 발생했습니다."; + } catch (Exception e) { + return "USER 서비스 호출 중 오류가 발생했습니다."; + } } -} +} \ No newline at end of file diff --git a/src/main/java/com/example/auth/global/exception/DownstreamServiceException.java b/src/main/java/com/example/auth/global/exception/DownstreamServiceException.java new file mode 100644 index 0000000..595c717 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/DownstreamServiceException.java @@ -0,0 +1,24 @@ +package com.example.auth.global.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatusCode; + +@Getter +public class DownstreamServiceException extends RuntimeException { + + private final String serviceName; + private final HttpStatusCode statusCode; + private final String responseBody; + + public DownstreamServiceException( + String serviceName, + HttpStatusCode statusCode, + String message, + String responseBody + ) { + super(message); + this.serviceName = serviceName; + this.statusCode = statusCode; + this.responseBody = responseBody; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java index 349483b..291c565 100644 --- a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java @@ -43,6 +43,46 @@ public ResponseEntity> googleOauthHandleException(GoogleOaut .body(ApiResponse.fail("[ERROR: Auth/Oauth/Google] " + e.getMessage())); } + @ExceptionHandler(DownstreamServiceException.class) + public ResponseEntity> downstreamServiceHandleException( + DownstreamServiceException e + ) { + if (e.getStatusCode().is4xxClientError()) { + log.warn( + "Downstream service client error. service={}, status={}, body={}", + e.getServiceName(), + e.getStatusCode(), + e.getResponseBody() + ); + + return ResponseEntity.status(e.getStatusCode()) + .body(ApiResponse.fail(e.getMessage())); + } + + if (e.getStatusCode().isSameCodeAs(HttpStatus.SERVICE_UNAVAILABLE)) { + log.error( + "Downstream service unavailable. service={}, body={}", + e.getServiceName(), + e.getResponseBody() + ); + + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(ApiResponse.fail("[ERROR: Auth/Downstream/" + e.getServiceName() + + "] " + e.getServiceName() + " 서비스에 연결할 수 없습니다.")); + } + + log.error( + "Downstream service server error. service={}, status={}, body={}", + e.getServiceName(), + e.getStatusCode(), + e.getResponseBody() + ); + + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(ApiResponse.fail("[ERROR: Auth/Downstream/" + e.getServiceName() + + "] " + e.getServiceName() + " 서비스 호출 중 오류가 발생했습니다.")); + } + @ExceptionHandler(AuthException.class) public ResponseEntity> authHandleException(AuthException e) { log.error("AuthException", e); From 502a10c6c117fa38959a7a83f53b1c8ade5d8a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A3=BC=ED=99=98?= Date: Mon, 20 Jul 2026 17:30:32 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=84=A4=EC=9D=B4=EB=B2=84,=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20oauth=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API_SPEC.yaml | 86 +++++++++++++++++- .../auth/controller/AuthController.java | 36 ++++++++ .../auth/global/client/KakaoOauthClient.java | 91 +++++++++++++++++++ .../auth/global/client/NaverOauthClient.java | 84 +++++++++++++++++ .../auth/global/client/UserServiceClient.java | 18 ++-- .../dto/request/UserGoogleOauthRequest.java | 19 ---- .../client/dto/request/UserOauthRequest.java | 32 +++++++ .../dto/response/GoogleUserInfoResponse.java | 12 +++ .../dto/response/KakaoTokenResponse.java | 20 ++++ .../dto/response/KakaoUserInfoResponse.java | 47 ++++++++++ .../dto/response/NaverTokenResponse.java | 18 ++++ .../dto/response/NaverUserInfoResponse.java | 34 +++++++ .../client/dto/response/OauthUserProfile.java | 11 +++ .../auth/global/config/SecurityConfig.java | 4 + .../exception/GlobalExceptionHandler.java | 16 ++++ .../global/exception/KakaoOauthException.java | 7 ++ .../global/exception/NaverOauthException.java | 7 ++ .../com/example/auth/service/AuthService.java | 68 +++++++++++++- .../example/auth/service/AuthValidator.java | 19 +++- src/main/resources/application.yml | 15 +++ src/test/resources/application.yml | 15 +++ 21 files changed, 625 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/example/auth/global/client/KakaoOauthClient.java create mode 100644 src/main/java/com/example/auth/global/client/NaverOauthClient.java delete 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/request/UserOauthRequest.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/KakaoTokenResponse.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/KakaoUserInfoResponse.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/NaverTokenResponse.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/NaverUserInfoResponse.java create mode 100644 src/main/java/com/example/auth/global/client/dto/response/OauthUserProfile.java create mode 100644 src/main/java/com/example/auth/global/exception/KakaoOauthException.java create mode 100644 src/main/java/com/example/auth/global/exception/NaverOauthException.java diff --git a/API_SPEC.yaml b/API_SPEC.yaml index 69f5c01..57f0a4b 100644 --- a/API_SPEC.yaml +++ b/API_SPEC.yaml @@ -7,7 +7,73 @@ servers: - url: "Needs confirmation" description: "Needs confirmation" tags: [] -paths: {} +paths: + /auth/oauth/naver: + get: + summary: "Redirect to Naver OAuth" + parameters: + - name: state + in: query + required: false + schema: + type: string + responses: + "302": + description: "Redirects to Naver authorization endpoint." + /auth/oauth/naver/callback: + get: + summary: "Handle Naver OAuth callback" + parameters: + - name: code + in: query + required: true + schema: + type: string + - name: state + in: query + required: false + schema: + type: string + responses: + "200": + description: "JWT tokens returned after Naver login." + "400": + $ref: "#/components/responses/BadRequestError" + "502": + $ref: "#/components/responses/NaverOauthError" + /auth/oauth/kakao: + get: + summary: "Redirect to Kakao OAuth" + parameters: + - name: state + in: query + required: false + schema: + type: string + responses: + "302": + description: "Redirects to Kakao authorization endpoint." + /auth/oauth/kakao/callback: + get: + summary: "Handle Kakao OAuth callback" + parameters: + - name: code + in: query + required: true + schema: + type: string + - name: state + in: query + required: false + schema: + type: string + responses: + "200": + description: "JWT tokens returned after Kakao login." + "400": + $ref: "#/components/responses/BadRequestError" + "502": + $ref: "#/components/responses/KakaoOauthError" components: schemas: ErrorResponse: @@ -50,6 +116,24 @@ components: example: message: "[ERROR: Auth/Oauth/Google] Google Access Token을 발급받을 수 없습니다." data: null + NaverOauthError: + description: "Naver OAuth request failed." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: Auth/Oauth/Naver] Naver Access Token을 발급받을 수 없습니다." + data: null + KakaoOauthError: + description: "Kakao OAuth request failed." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + message: "[ERROR: Auth/Oauth/Kakao] Kakao Access Token을 발급받을 수 없습니다." + data: null InternalServerError: description: "Unexpected server error." content: diff --git a/src/main/java/com/example/auth/controller/AuthController.java b/src/main/java/com/example/auth/controller/AuthController.java index 3465d63..2824e10 100644 --- a/src/main/java/com/example/auth/controller/AuthController.java +++ b/src/main/java/com/example/auth/controller/AuthController.java @@ -57,6 +57,42 @@ public ResponseEntity> googleOauthCallb return ResponseEntity.ok(ResponseUtil.success("Google 로그인에 성공했습니다.", response)); } + @GetMapping("/oauth/naver") + public ResponseEntity naverOauth(@RequestParam(required = false) String state) { + URI redirectUri = authService.createNaverAuthorizationUri(state); + return ResponseEntity.status(HttpStatus.FOUND) + .location(redirectUri) + .build(); + } + + @GetMapping("/oauth/naver/callback") + public ResponseEntity> naverOauthCallback( + @RequestParam String code, + @RequestParam(required = false) String state + ) { + OauthGoogleCallbackResponse response = + authService.loginWithNaver(code, state); + return ResponseEntity.ok(ResponseUtil.success("Naver 로그인에 성공했습니다.", response)); + } + + @GetMapping("/oauth/kakao") + public ResponseEntity kakaoOauth(@RequestParam(required = false) String state) { + URI redirectUri = authService.createKakaoAuthorizationUri(state); + return ResponseEntity.status(HttpStatus.FOUND) + .location(redirectUri) + .build(); + } + + @GetMapping("/oauth/kakao/callback") + public ResponseEntity> kakaoOauthCallback( + @RequestParam String code, + @RequestParam(required = false) String state + ) { + OauthGoogleCallbackResponse response = + authService.loginWithKakao(code, state); + return ResponseEntity.ok(ResponseUtil.success("Kakao 로그인에 성공했습니다.", response)); + } + @PostMapping("/refresh") public ResponseEntity> refresh(@RequestBody RefreshRequest request) { // Refresh Token이 유효하면 새로운 토큰 묶음을 발급합니다. diff --git a/src/main/java/com/example/auth/global/client/KakaoOauthClient.java b/src/main/java/com/example/auth/global/client/KakaoOauthClient.java new file mode 100644 index 0000000..4274685 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/KakaoOauthClient.java @@ -0,0 +1,91 @@ +package com.example.auth.global.client; + +import com.example.auth.global.client.dto.response.KakaoTokenResponse; +import com.example.auth.global.client.dto.response.KakaoUserInfoResponse; +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 KakaoOauthClient { + + 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 KakaoOauthClient( + @Value("${oauth.kakao.client-id}") String clientId, + @Value("${oauth.kakao.client-secret}") String clientSecret, + @Value("${oauth.kakao.redirect-uri}") String redirectUri, + @Value("${oauth.kakao.authorization-uri}") String authorizationUri, + @Value("${oauth.kakao.token-uri}") String tokenUri, + @Value("${oauth.kakao.user-info-uri}") String userInfoUri, + @Value("${oauth.kakao.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("response_type", "code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri); + + if (StringUtils.hasText(scope)) { + builder.queryParam("scope", scope); + } + + if (StringUtils.hasText(state)) { + builder.queryParam("state", state); + } + + return builder.build() + .encode() + .toUri(); + } + + public KakaoTokenResponse requestToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + if (StringUtils.hasText(clientSecret)) { + body.add("client_secret", clientSecret); + } + + return restClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(KakaoTokenResponse.class); + } + + public KakaoUserInfoResponse requestUserInfo(String accessToken) { + return restClient.get() + .uri(userInfoUri) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .body(KakaoUserInfoResponse.class); + } +} diff --git a/src/main/java/com/example/auth/global/client/NaverOauthClient.java b/src/main/java/com/example/auth/global/client/NaverOauthClient.java new file mode 100644 index 0000000..9ff6690 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/NaverOauthClient.java @@ -0,0 +1,84 @@ +package com.example.auth.global.client; + +import com.example.auth.global.client.dto.response.NaverTokenResponse; +import com.example.auth.global.client.dto.response.NaverUserInfoResponse; +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 NaverOauthClient { + + 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; + + public NaverOauthClient( + @Value("${oauth.naver.client-id}") String clientId, + @Value("${oauth.naver.client-secret}") String clientSecret, + @Value("${oauth.naver.redirect-uri}") String redirectUri, + @Value("${oauth.naver.authorization-uri}") String authorizationUri, + @Value("${oauth.naver.token-uri}") String tokenUri, + @Value("${oauth.naver.user-info-uri}") String userInfoUri + ) { + this.restClient = RestClient.create(); + this.clientId = clientId; + this.clientSecret = clientSecret; + this.redirectUri = redirectUri; + this.authorizationUri = authorizationUri; + this.tokenUri = tokenUri; + this.userInfoUri = userInfoUri; + } + + public URI createAuthorizationUri(String state) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(authorizationUri) + .queryParam("response_type", "code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri); + + if (StringUtils.hasText(state)) { + builder.queryParam("state", state); + } + + return builder.build() + .encode() + .toUri(); + } + + public NaverTokenResponse requestToken(String code, String state) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("code", code); + + if (StringUtils.hasText(state)) { + body.add("state", state); + } + + return restClient.post() + .uri(tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .body(NaverTokenResponse.class); + } + + public NaverUserInfoResponse requestUserInfo(String accessToken) { + return restClient.get() + .uri(userInfoUri) + .headers(headers -> headers.setBearerAuth(accessToken)) + .retrieve() + .body(NaverUserInfoResponse.class); + } +} 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 b3a4ff0..60262b6 100644 --- a/src/main/java/com/example/auth/global/client/UserServiceClient.java +++ b/src/main/java/com/example/auth/global/client/UserServiceClient.java @@ -1,8 +1,8 @@ package com.example.auth.global.client; import com.example.auth.dto.request.SignInRequest; -import com.example.auth.global.client.dto.request.UserGoogleOauthRequest; -import com.example.auth.global.client.dto.response.GoogleUserInfoResponse; +import com.example.auth.global.client.dto.request.UserOauthRequest; +import com.example.auth.global.client.dto.response.OauthUserProfile; import com.example.auth.global.client.dto.response.UserAuthResponse; import com.example.auth.global.exception.DownstreamServiceException; import com.example.auth.global.exception.UnauthorizedException; @@ -54,18 +54,12 @@ public UserAuthResponse authenticate(SignInRequest request) { } } - public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { - UserGoogleOauthRequest userRequest = new UserGoogleOauthRequest( - request.providerId(), - request.email(), - request.emailVerified(), - request.name(), - request.imageUrl() - ); + public UserAuthResponse authenticateOauth(OauthUserProfile profile) { + UserOauthRequest userRequest = UserOauthRequest.from(profile); try { return restClient.post() - .uri("/internal/users/oauth/google") + .uri("/internal/users/oauth") .body(userRequest) .retrieve() .body(UserAuthResponse.class); @@ -131,4 +125,4 @@ private String extractMessage(String responseBody) { return "USER 서비스 호출 중 오류가 발생했습니다."; } } -} \ No newline at end of file +} 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 deleted file mode 100644 index c27ebad..0000000 --- a/src/main/java/com/example/auth/global/client/dto/request/UserGoogleOauthRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.auth.global.client.dto.request; - -import com.fasterxml.jackson.annotation.JsonProperty; - -public record UserGoogleOauthRequest( - @JsonProperty("provider_id") - String providerId, - - String email, - - @JsonProperty("email_verified") - Boolean emailVerified, - - String name, - - @JsonProperty("image_url") - String imageUrl -) { -} diff --git a/src/main/java/com/example/auth/global/client/dto/request/UserOauthRequest.java b/src/main/java/com/example/auth/global/client/dto/request/UserOauthRequest.java new file mode 100644 index 0000000..372ad53 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/request/UserOauthRequest.java @@ -0,0 +1,32 @@ +package com.example.auth.global.client.dto.request; + +import com.example.auth.global.client.dto.response.OauthUserProfile; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record UserOauthRequest( + String provider, + + @JsonProperty("provider_id") + String providerId, + + String email, + + @JsonProperty("email_verified") + Boolean emailVerified, + + String name, + + @JsonProperty("image_url") + String imageUrl +) { + public static UserOauthRequest from(OauthUserProfile profile) { + return new UserOauthRequest( + profile.provider(), + profile.providerId(), + profile.email(), + profile.emailVerified(), + profile.name(), + profile.imageUrl() + ); + } +} 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 index 9c74b7c..d021ded 100644 --- 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 @@ -16,4 +16,16 @@ public record GoogleUserInfoResponse( @JsonProperty("picture") String imageUrl ) { + private static final String PROVIDER = "GOOGLE"; + + public OauthUserProfile toProfile() { + return new OauthUserProfile( + PROVIDER, + providerId, + email, + emailVerified, + name, + imageUrl + ); + } } diff --git a/src/main/java/com/example/auth/global/client/dto/response/KakaoTokenResponse.java b/src/main/java/com/example/auth/global/client/dto/response/KakaoTokenResponse.java new file mode 100644 index 0000000..2a4bfd4 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/KakaoTokenResponse.java @@ -0,0 +1,20 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResponse( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("refresh_token") + String refreshToken, + + @JsonProperty("expires_in") + Long expiresIn, + + String scope +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/KakaoUserInfoResponse.java b/src/main/java/com/example/auth/global/client/dto/response/KakaoUserInfoResponse.java new file mode 100644 index 0000000..154f063 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/KakaoUserInfoResponse.java @@ -0,0 +1,47 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResponse( + Long id, + + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + private static final String PROVIDER = "KAKAO"; + + public record KakaoAccount( + String email, + + @JsonProperty("is_email_valid") + Boolean emailValid, + + @JsonProperty("is_email_verified") + Boolean emailVerified, + + Profile profile + ) { + } + + public record Profile( + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) { + } + + public OauthUserProfile toProfile() { + KakaoAccount account = kakaoAccount; + Profile profile = account == null ? null : account.profile(); + + return new OauthUserProfile( + PROVIDER, + id == null ? null : String.valueOf(id), + account == null ? null : account.email(), + account == null ? null : account.emailVerified(), + profile == null ? null : profile.nickname(), + profile == null ? null : profile.profileImageUrl() + ); + } +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/NaverTokenResponse.java b/src/main/java/com/example/auth/global/client/dto/response/NaverTokenResponse.java new file mode 100644 index 0000000..e59cf86 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/NaverTokenResponse.java @@ -0,0 +1,18 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record NaverTokenResponse( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("refresh_token") + String refreshToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("expires_in") + Long expiresIn +) { +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/NaverUserInfoResponse.java b/src/main/java/com/example/auth/global/client/dto/response/NaverUserInfoResponse.java new file mode 100644 index 0000000..b88edc3 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/NaverUserInfoResponse.java @@ -0,0 +1,34 @@ +package com.example.auth.global.client.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record NaverUserInfoResponse( + Response response +) { + private static final String PROVIDER = "NAVER"; + + public record Response( + String id, + String email, + String name, + + @JsonProperty("profile_image") + String profileImage + ) { + } + + public OauthUserProfile toProfile() { + if (response == null) { + return new OauthUserProfile(PROVIDER, null, null, null, null, null); + } + + return new OauthUserProfile( + PROVIDER, + response.id(), + response.email(), + null, + response.name(), + response.profileImage() + ); + } +} diff --git a/src/main/java/com/example/auth/global/client/dto/response/OauthUserProfile.java b/src/main/java/com/example/auth/global/client/dto/response/OauthUserProfile.java new file mode 100644 index 0000000..fa60556 --- /dev/null +++ b/src/main/java/com/example/auth/global/client/dto/response/OauthUserProfile.java @@ -0,0 +1,11 @@ +package com.example.auth.global.client.dto.response; + +public record OauthUserProfile( + String provider, + String providerId, + String email, + Boolean emailVerified, + String name, + String imageUrl +) { +} 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 3a0904f..6d0f9b2 100644 --- a/src/main/java/com/example/auth/global/config/SecurityConfig.java +++ b/src/main/java/com/example/auth/global/config/SecurityConfig.java @@ -31,6 +31,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/auth/refresh", "/auth/oauth/google", "/auth/oauth/google/callback", + "/auth/oauth/naver", + "/auth/oauth/naver/callback", + "/auth/oauth/kakao", + "/auth/oauth/kakao/callback", "/actuator/health" ).permitAll() .anyRequest().authenticated() diff --git a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java index 291c565..ef870b6 100644 --- a/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java @@ -43,6 +43,22 @@ public ResponseEntity> googleOauthHandleException(GoogleOaut .body(ApiResponse.fail("[ERROR: Auth/Oauth/Google] " + e.getMessage())); } + @ExceptionHandler(NaverOauthException.class) + public ResponseEntity> naverOauthHandleException(NaverOauthException e) { + log.error("NaverOauthException", e); + + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(ApiResponse.fail("[ERROR: Auth/Oauth/Naver] " + e.getMessage())); + } + + @ExceptionHandler(KakaoOauthException.class) + public ResponseEntity> kakaoOauthHandleException(KakaoOauthException e) { + log.error("KakaoOauthException", e); + + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(ApiResponse.fail("[ERROR: Auth/Oauth/Kakao] " + e.getMessage())); + } + @ExceptionHandler(DownstreamServiceException.class) public ResponseEntity> downstreamServiceHandleException( DownstreamServiceException e diff --git a/src/main/java/com/example/auth/global/exception/KakaoOauthException.java b/src/main/java/com/example/auth/global/exception/KakaoOauthException.java new file mode 100644 index 0000000..9b3b9c2 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/KakaoOauthException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class KakaoOauthException extends AuthException { + public KakaoOauthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/global/exception/NaverOauthException.java b/src/main/java/com/example/auth/global/exception/NaverOauthException.java new file mode 100644 index 0000000..78170f5 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/NaverOauthException.java @@ -0,0 +1,7 @@ +package com.example.auth.global.exception; + +public class NaverOauthException extends AuthException { + public NaverOauthException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/auth/service/AuthService.java b/src/main/java/com/example/auth/service/AuthService.java index b62e7c9..0871581 100644 --- a/src/main/java/com/example/auth/service/AuthService.java +++ b/src/main/java/com/example/auth/service/AuthService.java @@ -7,11 +7,20 @@ 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.KakaoOauthClient; +import com.example.auth.global.client.NaverOauthClient; import com.example.auth.global.client.UserServiceClient; 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.KakaoTokenResponse; +import com.example.auth.global.client.dto.response.KakaoUserInfoResponse; +import com.example.auth.global.client.dto.response.NaverTokenResponse; +import com.example.auth.global.client.dto.response.NaverUserInfoResponse; +import com.example.auth.global.client.dto.response.OauthUserProfile; import com.example.auth.global.client.dto.response.UserAuthResponse; import com.example.auth.global.exception.GoogleOauthException; +import com.example.auth.global.exception.KakaoOauthException; +import com.example.auth.global.exception.NaverOauthException; import java.net.URI; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -20,6 +29,8 @@ public class AuthService { private final GoogleOauthClient googleOauthClient; + private final NaverOauthClient naverOauthClient; + private final KakaoOauthClient kakaoOauthClient; private final UserServiceClient userServiceClient; private final AuthValidator authValidator; private final RoleProcessor roleProcessor; @@ -27,12 +38,16 @@ public class AuthService { public AuthService( GoogleOauthClient googleOauthClient, + NaverOauthClient naverOauthClient, + KakaoOauthClient kakaoOauthClient, UserServiceClient userServiceClient, AuthValidator authValidator, RoleProcessor roleProcessor, TokenService tokenService ) { this.googleOauthClient = googleOauthClient; + this.naverOauthClient = naverOauthClient; + this.kakaoOauthClient = kakaoOauthClient; this.userServiceClient = userServiceClient; this.authValidator = authValidator; this.roleProcessor = roleProcessor; @@ -87,8 +102,59 @@ public OauthGoogleCallbackResponse loginWithGoogle(String code, String state) { GoogleUserInfoResponse googleUser = googleOauthClient.requestUserInfo(googleToken.accessToken()); + return issueOauthLoginResponse(googleUser.toProfile(), "Google"); + } + + public URI createNaverAuthorizationUri(String state) { + return naverOauthClient.createAuthorizationUri(state); + } + + public OauthGoogleCallbackResponse loginWithNaver(String code, String state) { + + authValidator.validateOauthAuthorizationCode(code, "Naver"); + + NaverTokenResponse naverToken = + naverOauthClient.requestToken(code, state); + + if (naverToken == null || !StringUtils.hasText(naverToken.accessToken())) { + throw new NaverOauthException("Naver Access Token을 발급받을 수 없습니다."); + } + + NaverUserInfoResponse naverUser = + naverOauthClient.requestUserInfo(naverToken.accessToken()); + + return issueOauthLoginResponse(naverUser.toProfile(), "Naver"); + } + + public URI createKakaoAuthorizationUri(String state) { + return kakaoOauthClient.createAuthorizationUri(state); + } + + public OauthGoogleCallbackResponse loginWithKakao(String code, String state) { + + authValidator.validateOauthAuthorizationCode(code, "Kakao"); + + KakaoTokenResponse kakaoToken = + kakaoOauthClient.requestToken(code); + + if (kakaoToken == null || !StringUtils.hasText(kakaoToken.accessToken())) { + throw new KakaoOauthException("Kakao Access Token을 발급받을 수 없습니다."); + } + + KakaoUserInfoResponse kakaoUser = + kakaoOauthClient.requestUserInfo(kakaoToken.accessToken()); + + return issueOauthLoginResponse(kakaoUser.toProfile(), "Kakao"); + } + + private OauthGoogleCallbackResponse issueOauthLoginResponse( + OauthUserProfile profile, + String providerName + ) { + authValidator.validateOauthProfile(profile, providerName); + UserAuthResponse user = - userServiceClient.authenticateGoogle(googleUser); + userServiceClient.authenticateOauth(profile); authValidator.validateAuthenticatedUser(user); diff --git a/src/main/java/com/example/auth/service/AuthValidator.java b/src/main/java/com/example/auth/service/AuthValidator.java index 3ffe492..9795a35 100644 --- a/src/main/java/com/example/auth/service/AuthValidator.java +++ b/src/main/java/com/example/auth/service/AuthValidator.java @@ -3,6 +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.response.OauthUserProfile; import com.example.auth.global.client.dto.response.UserAuthResponse; import com.example.auth.global.exception.BadRequestException; import com.example.auth.global.exception.UnauthorizedException; @@ -24,8 +25,24 @@ public void validateRefreshRequest(RefreshRequest request) { } public void validateGoogleAuthorizationCode(String code) { + validateOauthAuthorizationCode(code, "Google"); + } + + public void validateOauthAuthorizationCode(String code, String providerName) { if (!StringUtils.hasText(code)) { - throw new BadRequestException("Google Authorization Code를 입력해주세요."); + throw new BadRequestException(providerName + " Authorization Code를 입력해주세요."); + } + } + + public void validateOauthProfile(OauthUserProfile profile, String providerName) { + if (profile == null + || !StringUtils.hasText(profile.provider()) + || !StringUtils.hasText(profile.providerId())) { + throw new BadRequestException(providerName + " 사용자 정보를 확인할 수 없습니다."); + } + + if (!StringUtils.hasText(profile.email())) { + throw new BadRequestException(providerName + " 이메일 제공 동의가 필요합니다."); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0e7d3c5..7734ee1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,3 +46,18 @@ oauth: token-uri: "https://oauth2.googleapis.com/token" user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" scope: "openid email profile" + naver: + client-id: "${NAVER_CLIENT_ID:}" + client-secret: "${NAVER_CLIENT_SECRET:}" + redirect-uri: "${NAVER_REDIRECT_URI:http://localhost:8080/auth/oauth/naver/callback}" + authorization-uri: "https://nid.naver.com/oauth2.0/authorize" + token-uri: "https://nid.naver.com/oauth2.0/token" + user-info-uri: "https://openapi.naver.com/v1/nid/me" + kakao: + client-id: "${KAKAO_CLIENT_ID:}" + client-secret: "${KAKAO_CLIENT_SECRET:}" + redirect-uri: "${KAKAO_REDIRECT_URI:http://localhost:8080/auth/oauth/kakao/callback}" + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + scope: "account_email profile_nickname profile_image" diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c89bf67..abea433 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -21,3 +21,18 @@ oauth: token-uri: "https://oauth2.googleapis.com/token" user-info-uri: "https://www.googleapis.com/oauth2/v3/userinfo" scope: "openid email profile" + naver: + client-id: test-naver-client-id + client-secret: test-naver-client-secret + redirect-uri: "http://localhost:8080/auth/oauth/naver/callback" + authorization-uri: "https://nid.naver.com/oauth2.0/authorize" + token-uri: "https://nid.naver.com/oauth2.0/token" + user-info-uri: "https://openapi.naver.com/v1/nid/me" + kakao: + client-id: test-kakao-client-id + client-secret: test-kakao-client-secret + redirect-uri: "http://localhost:8080/auth/oauth/kakao/callback" + authorization-uri: "https://kauth.kakao.com/oauth/authorize" + token-uri: "https://kauth.kakao.com/oauth/token" + user-info-uri: "https://kapi.kakao.com/v2/user/me" + scope: "account_email profile_nickname profile_image"