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 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/client/UserServiceClient.java b/src/main/java/com/example/auth/global/client/UserServiceClient.java index ef851c8..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,20 +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.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) @@ -22,11 +35,23 @@ 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 (RestClientResponseException e) { + if (e.getStatusCode().isSameCodeAs(HttpStatus.UNAUTHORIZED)) { + throw new UnauthorizedException("이메일 또는 비밀번호가 일치하지 않습니다."); + } + + throw convertToDownstreamException(e); + + } catch (RestClientException e) { + throw convertToConnectionException(e); + } } public UserAuthResponse authenticateGoogle(GoogleUserInfoResponse request) { @@ -38,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/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/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 new file mode 100644 index 0000000..291c565 --- /dev/null +++ b/src/main/java/com/example/auth/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,101 @@ +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(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); + + 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: ?/?] 서버 내부 오류가 발생했습니다.")); + } +} 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과 일치하지 않습니다." ); }