diff --git a/src/main/java/com/swyp/app/domain/oauth/controller/AuthController.java b/src/main/java/com/swyp/app/domain/oauth/controller/AuthController.java index ea9f7cb..020a89b 100644 --- a/src/main/java/com/swyp/app/domain/oauth/controller/AuthController.java +++ b/src/main/java/com/swyp/app/domain/oauth/controller/AuthController.java @@ -2,10 +2,13 @@ import com.swyp.app.domain.oauth.dto.LoginRequest; import com.swyp.app.domain.oauth.dto.LoginResponse; +import com.swyp.app.domain.oauth.dto.LogoutResponse; +import com.swyp.app.domain.oauth.dto.WithdrawResponse; import com.swyp.app.domain.oauth.service.AuthService; import com.swyp.app.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -13,7 +16,7 @@ @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "Auth", description = "인증 API") +@Tag(name = "인증 (Auth)", description = "인증 API") public class AuthController { private final AuthService authService; @@ -37,19 +40,19 @@ public ApiResponse refresh( @Operation(summary = "로그아웃") @PostMapping("/auth/logout") - public ApiResponse logout( + public ApiResponse logout( @AuthenticationPrincipal Long userId ) { authService.logout(userId); - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(new LogoutResponse(true)); } @Operation(summary = "회원 탈퇴") @DeleteMapping("/me") - public ApiResponse withdraw( + public ApiResponse withdraw( @AuthenticationPrincipal Long userId ) { authService.withdraw(userId); - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(new WithdrawResponse(true)); } } \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/oauth/dto/LoginRequest.java b/src/main/java/com/swyp/app/domain/oauth/dto/LoginRequest.java index c397553..bd07d04 100644 --- a/src/main/java/com/swyp/app/domain/oauth/dto/LoginRequest.java +++ b/src/main/java/com/swyp/app/domain/oauth/dto/LoginRequest.java @@ -1,9 +1,11 @@ package com.swyp.app.domain.oauth.dto; +import lombok.AllArgsConstructor; import lombok.Getter; // 클라이언트가 서버로 요청을 보낼 때, 데이터를 담는 DTO @Getter +@AllArgsConstructor public class LoginRequest { private String authorizationCode; private String redirectUri; diff --git a/src/main/java/com/swyp/app/domain/oauth/dto/LoginResponse.java b/src/main/java/com/swyp/app/domain/oauth/dto/LoginResponse.java index 74503c8..3ac1194 100644 --- a/src/main/java/com/swyp/app/domain/oauth/dto/LoginResponse.java +++ b/src/main/java/com/swyp/app/domain/oauth/dto/LoginResponse.java @@ -1,18 +1,19 @@ package com.swyp.app.domain.oauth.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.AllArgsConstructor; import lombok.Getter; // 서버가 클라이언트에게 데이터를 돌려줄 때, 데이터를 담는 DTO @Getter @AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class LoginResponse { private String accessToken; private String refreshToken; - private String userTag; // 회의에서 userTag 반환하는 것으로 통일했기 때문에 userId 대신 userTag 반환 - - @JsonProperty("is_new_user") + private String userTag; private boolean isNewUser; private String status; } \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/oauth/dto/LogoutResponse.java b/src/main/java/com/swyp/app/domain/oauth/dto/LogoutResponse.java new file mode 100644 index 0000000..b1d00f7 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/oauth/dto/LogoutResponse.java @@ -0,0 +1,13 @@ +package com.swyp.app.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class LogoutResponse { + private final boolean loggedOut; +} \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/oauth/dto/WithdrawResponse.java b/src/main/java/com/swyp/app/domain/oauth/dto/WithdrawResponse.java new file mode 100644 index 0000000..0c369b8 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/oauth/dto/WithdrawResponse.java @@ -0,0 +1,13 @@ +package com.swyp.app.domain.oauth.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class WithdrawResponse { + private final boolean withdrawn; +} diff --git a/src/main/java/com/swyp/app/domain/oauth/jwt/JwtFilter.java b/src/main/java/com/swyp/app/domain/oauth/jwt/JwtFilter.java index de373c7..eecc671 100644 --- a/src/main/java/com/swyp/app/domain/oauth/jwt/JwtFilter.java +++ b/src/main/java/com/swyp/app/domain/oauth/jwt/JwtFilter.java @@ -1,7 +1,9 @@ package com.swyp.app.domain.oauth.jwt; +import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; +import com.swyp.app.global.common.response.ApiResponse; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -22,8 +24,8 @@ public class JwtFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); - // 인증 제외 경로 private static final List WHITELIST = List.of( "/api/v1/auth/login", "/api/v1/auth/refresh", @@ -38,41 +40,58 @@ protected void doFilterInternal(HttpServletRequest request, String requestUri = request.getRequestURI(); - // 화이트리스트 경로는 토큰 검증 스킵 if (isWhitelisted(requestUri)) { filterChain.doFilter(request, response); return; } - // Authorization 헤더에서 토큰 추출 - String token = resolveToken(request); - - if (token == null) { - throw new CustomException(ErrorCode.AUTH_UNAUTHORIZED); - } + try { + String token = resolveToken(request); + + if (token == null) { + log.error("[JwtFilter] Token missing for URI: {}", requestUri); + setErrorResponse(response, ErrorCode.AUTH_UNAUTHORIZED); + return; + } + + if (!jwtProvider.validateToken(token)) { + log.error("[JwtFilter] Invalid or Expired token for URI: {}", requestUri); + setErrorResponse(response, ErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + return; + } + + Long userId = jwtProvider.getUserId(token); + String role = jwtProvider.getRole(token); + String authorityName = (role != null && role.startsWith("ROLE_")) ? role : "ROLE_" + role; + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userId, + null, + role != null ? List.of(new SimpleGrantedAuthority(authorityName)) : List.of() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); - if (!jwtProvider.validateToken(token)) { - throw new CustomException(ErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + } catch (Exception e) { + log.error("[JwtFilter] Filter Error: {}", e.getMessage()); + setErrorResponse(response, ErrorCode.INTERNAL_SERVER_ERROR); } + } - // 토큰에서 userId와 role 추출 - Long userId = jwtProvider.getUserId(token); - String role = jwtProvider.getRole(token); - - // 권한 문자열에 "ROLE_" 접두사가 없으면 붙여줌 (스프링 시큐리티 규칙) - String authorityName = (role != null && role.startsWith("ROLE_")) ? role : "ROLE_" + role; - - // 추출한 권한을 SecurityContext 에 주입 - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - userId, - null, - role != null ? List.of(new SimpleGrantedAuthority(authorityName)) : List.of() - ); + private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(errorCode.getHttpStatus().value()); - SecurityContextHolder.getContext().setAuthentication(authentication); + ApiResponse errorResponse = ApiResponse.onFailure( + errorCode.getHttpStatus().value(), + errorCode.getCode(), + errorCode.getMessage() + ); - filterChain.doFilter(request, response); + String result = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(result); } private String resolveToken(HttpServletRequest request) { diff --git a/src/main/java/com/swyp/app/global/common/logging/ApiLoggingAspect.java b/src/main/java/com/swyp/app/global/common/logging/ApiLoggingAspect.java new file mode 100644 index 0000000..19bd5ec --- /dev/null +++ b/src/main/java/com/swyp/app/global/common/logging/ApiLoggingAspect.java @@ -0,0 +1,52 @@ +package com.swyp.app.global.common.logging; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Slf4j +@Aspect +@Component +public class ApiLoggingAspect { + + // 1. 모든 컨트롤러 패키지 하위의 메서드를 타겟으로 잡습니다. + @Around("execution(* com.swyp.app.domain..controller..*.*(..))") + public Object logApiExecution(ProceedingJoinPoint joinPoint) throws Throwable { + // 2. 현재 요청의 HttpServletRequest 가져오기 + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + HttpServletRequest request = attributes != null ? attributes.getRequest() : null; + + String method = (request != null) ? request.getMethod() : "UNKNOWN"; + String uri = (request != null) ? request.getRequestURI() : "UNKNOWN"; + String className = joinPoint.getSignature().getDeclaringType().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + long start = System.currentTimeMillis(); + + try { + // 3. 실제 비즈니스 로직 실행 + Object result = joinPoint.proceed(); + + long executionTime = System.currentTimeMillis() - start; + + // 4. 성공 로그 기록 + log.info(">>> [API SUCCESS] {}: {} | Controller: {}.{} | Time: {}ms", + method, uri, className, methodName, executionTime); + + return result; + } catch (Exception e) { + long executionTime = System.currentTimeMillis() - start; + + // 5. 에러 로그 기록 (에러 발생 시에도 시간 측정) + log.error(">>> [API ERROR] {}: {} | Controller: {}.{} | Time: {}ms | Message: {}", + method, uri, className, methodName, executionTime, e.getMessage()); + + throw e; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 754cfdb..1a888cb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,13 +29,13 @@ spring: location: ${GCP_CREDENTIALS_PATH} # AWS S3 설정 (Mock 사용 시에도 필드는 필요) aws: - s3: - bucket: ${AWS_S3_BUCKET} region: static: ${AWS_REGION} credentials: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} + s3: + bucket: ${AWS_S3_BUCKET} # 3. 인증 및 보안 설정 (OAuth2, JWT) oauth: diff --git a/src/test/java/com/swyp/app/domain/oauth/service/OAuthServiceTest.java b/src/test/java/com/swyp/app/domain/oauth/service/OAuthServiceTest.java new file mode 100644 index 0000000..a77bb0b --- /dev/null +++ b/src/test/java/com/swyp/app/domain/oauth/service/OAuthServiceTest.java @@ -0,0 +1,84 @@ +package com.swyp.app.domain.oauth.service; + +import com.swyp.app.domain.oauth.client.GoogleOAuthClient; +import com.swyp.app.domain.oauth.client.KakaoOAuthClient; +import com.swyp.app.domain.oauth.dto.LoginRequest; +import com.swyp.app.domain.oauth.dto.LoginResponse; +import com.swyp.app.domain.oauth.dto.OAuthUserInfo; +import com.swyp.app.domain.oauth.repository.AuthRefreshTokenRepository; +import com.swyp.app.domain.oauth.repository.UserSocialAccountRepository; +import com.swyp.app.domain.oauth.jwt.JwtProvider; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.entity.UserRole; +import com.swyp.app.domain.user.entity.UserStatus; +import com.swyp.app.domain.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OAuthServiceTest { + + @Mock private KakaoOAuthClient kakaoOAuthClient; + @Mock private GoogleOAuthClient googleOAuthClient; + @Mock private UserRepository userRepository; + @Mock private UserSocialAccountRepository socialAccountRepository; + @Mock private AuthRefreshTokenRepository refreshTokenRepository; + @Mock private JwtProvider jwtProvider; + + private AuthService authService; + + @BeforeEach + void setUp() { + // 수동 주입으로 안정성 확보 + authService = new AuthService( + kakaoOAuthClient, googleOAuthClient, userRepository, + socialAccountRepository, refreshTokenRepository, jwtProvider + ); + } + + @Test + void login_카카오_기존유저_로그인_성공() { + // 1. 준비 (Given) + String provider = "KAKAO"; + LoginRequest request = new LoginRequest("auth-code", "redirect-uri"); + OAuthUserInfo userInfo = new OAuthUserInfo("kakao_123", "bex@test.com", "profile_url"); + + // 유저 엔티티에 ID가 없으므로 식별자 필드만 세팅 (UserTag 등) + User user = User.builder() + .userTag("pique-test") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + + // 2. Mock 설정 (anyString()을 사용하여 null이 아닌 어떤 문자열이든 대응) + when(kakaoOAuthClient.getAccessToken(anyString(), anyString())).thenReturn("mock-access-token"); + when(kakaoOAuthClient.getUserInfo(anyString())).thenReturn(userInfo); // 여기서 null이 안 들어가게 고정 + + var socialAccount = mock(com.swyp.app.domain.oauth.entity.UserSocialAccount.class); + when(socialAccount.getUser()).thenReturn(user); + when(socialAccountRepository.findByProviderAndProviderUserId(anyString(), anyString())) + .thenReturn(Optional.of(socialAccount)); + + // ID가 없더라도 createAccessToken의 첫 번째 인자가 무엇이든 통과하게 any() 사용 + when(jwtProvider.createAccessToken(any(), anyString())).thenReturn("jwt-access"); + when(jwtProvider.createRefreshToken()).thenReturn("jwt-refresh"); + + // 3. 실행 (When) + LoginResponse response = authService.login(provider, request); + + // 4. 검증 (Then) + assertThat(response.getAccessToken()).isEqualTo("jwt-access"); + assertThat(response.isNewUser()).isFalse(); + verify(refreshTokenRepository).save(any()); + } +} \ No newline at end of file