Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@

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.*;

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "인증 API")
@Tag(name = "인증 (Auth)", description = "인증 API")
public class AuthController {

private final AuthService authService;
Expand All @@ -37,19 +40,19 @@ public ApiResponse<LoginResponse> refresh(

@Operation(summary = "로그아웃")
@PostMapping("/auth/logout")
public ApiResponse<Void> logout(
public ApiResponse<LogoutResponse> logout(
@AuthenticationPrincipal Long userId
) {
authService.logout(userId);
return ApiResponse.onSuccess(null);
return ApiResponse.onSuccess(new LogoutResponse(true));
}

@Operation(summary = "회원 탈퇴")
@DeleteMapping("/me")
public ApiResponse<Void> withdraw(
public ApiResponse<WithdrawResponse> withdraw(
@AuthenticationPrincipal Long userId
) {
authService.withdraw(userId);
return ApiResponse.onSuccess(null);
return ApiResponse.onSuccess(new WithdrawResponse(true));
}
}
2 changes: 2 additions & 0 deletions src/main/java/com/swyp/app/domain/oauth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/main/java/com/swyp/app/domain/oauth/dto/LogoutResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/main/java/com/swyp/app/domain/oauth/dto/WithdrawResponse.java
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 45 additions & 26 deletions src/main/java/com/swyp/app/domain/oauth/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,8 +24,8 @@
public class JwtFilter extends OncePerRequestFilter {

private final JwtProvider jwtProvider;
private final ObjectMapper objectMapper = new ObjectMapper();

// 인증 제외 경로
private static final List<String> WHITELIST = List.of(
"/api/v1/auth/login",
"/api/v1/auth/refresh",
Expand All @@ -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<Void> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
4 changes: 2 additions & 2 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}