diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java index e9174a7b..69bd4cbe 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/controller/OauthLoginController.java @@ -15,6 +15,7 @@ import org.sejongisc.backend.user.service.UserService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,7 +24,7 @@ @Slf4j @RestController -@RequestMapping("/auth") +@RequestMapping("/api/auth") @RequiredArgsConstructor public class OauthLoginController { @@ -52,6 +53,16 @@ public class OauthLoginController { @Value("${github.redirect.uri}") private String githubRedirectUri; + + + + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + log.info("[SIGNUP] request: {}", request.getEmail()); + SignupResponse response = userService.signUp(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + @PostMapping("/login") public ResponseEntity login(@Valid @RequestBody LoginRequest request) { @@ -144,7 +155,7 @@ public ResponseEntity OauthLogin(@PathVariable("provider") String }; // Access 토큰 발급 - String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole()); + String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail()); String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); @@ -176,7 +187,7 @@ public ResponseEntity OauthLogin(@PathVariable("provider") String @PostMapping("/logout") public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String authorizationHeader) { - // 1️⃣ 헤더 유효성 검사 + // 헤더 유효성 검사 if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { return ResponseEntity.badRequest() .body(Map.of("message", "잘못된 Authorization 헤더 형식입니다.")); @@ -184,7 +195,7 @@ public ResponseEntity logout(@RequestHeader(value = "Authorization", required String token = authorizationHeader.substring(7); - // 2️⃣ 예외 처리 및 멱등성 보장 + // 예외 처리 및 멱등성 보장 try { loginService.logout(token); } catch (JwtException | IllegalArgumentException e) { @@ -195,7 +206,7 @@ public ResponseEntity logout(@RequestHeader(value = "Authorization", required // 내부 예외는 500으로 보내지 않고 안전하게 처리 } - // 3️⃣ Refresh Token 쿠키 삭제 + // Refresh Token 쿠키 삭제 ResponseCookie deleteCookie = ResponseCookie.from("refresh", "") .httpOnly(true) .secure(true) diff --git a/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java b/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java index 346f29b7..8f1b390e 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java @@ -10,11 +10,12 @@ @Setter @NoArgsConstructor @AllArgsConstructor +@Builder public class RefreshToken { @Id @Column(name = "user_id", nullable = false, columnDefinition = "uuid") - private UUID userid; + private UUID userId; @Column(nullable = false, length = 500) private String token; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java b/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java index c27f24f4..33e318ed 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java @@ -8,7 +8,7 @@ public interface RefreshTokenRepository extends JpaRepository { - Optional findByUserid(UUID userId); + Optional findByUserId(UUID userId); void deleteByUserId(UUID userId); } diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java index 4c142afe..1ab7d953 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java @@ -43,9 +43,22 @@ public LoginResponse login(LoginRequest request) { throw new CustomException(ErrorCode.UNAUTHORIZED); } - String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole()); + String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail()); String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); + // 기존 토큰 삭제 후 새로 저장 + refreshTokenRepository.findByUserId(user.getUserId()) + .ifPresent(refreshTokenRepository::delete); + + refreshTokenRepository.save( + org.sejongisc.backend.auth.entity.RefreshToken.builder() + .userId(user.getUserId()) + .token(refreshToken) + .build() + ); + + log.info("RefreshToken 저장 완료: userId={}", user.getUserId()); + return LoginResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java index 1f927082..ac8c20f8 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java @@ -36,11 +36,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> { auth - .requestMatchers("/user/signup", - "/auth/login", - "/auth/login/kakao", - "/auth/login/google", - "/auth/login/github", + .requestMatchers("/api/auth/signup", + "/api/auth/login", + "/api/auth/login/kakao", + "/api/auth/login/google", + "/api/auth/login/github", + "/api/auth/oauth/**", +// "/api/auth/refresh", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java index bc23d9fb..ac76865e 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java @@ -1,26 +1,22 @@ package org.sejongisc.backend.common.auth.jwt; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; - -import java.util.*; -import javax.crypto.SecretKey; - import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetailsService; import org.sejongisc.backend.user.entity.Role; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import javax.crypto.SecretKey; +import java.util.*; + +@Slf4j @Component @RequiredArgsConstructor public class JwtParser { @@ -30,54 +26,31 @@ public class JwtParser { private SecretKey secretKey; - @Value("${jwt.expireDate.accessToken}") - private long accessTokenValidityInMillis; - - @Value("${jwt.expireDate.refreshToken}") - private long refreshTokenValidityInMillis; - @PostConstruct public void init() { byte[] keyBytes = Base64.getDecoder().decode(rawSecretKey); this.secretKey = Keys.hmacShaKeyFor(keyBytes); } - // 토큰에서 사용자 ID 추출 - public UUID getUserIdFromToken(String token) { - Claims claims = parseClaims(token); - return UUID.fromString(claims.getSubject()); - } - - // 토큰에서 사용자 role 추출 - public Role getRoleFromToken(String token) { - Claims claims = parseClaims(token); - String roleStr = claims.get("role", String.class); - if (roleStr == null) { - throw new JwtException("JWT에 role 클레임이 없습니다."); // 명확한 인증 실패 예외 - } - try { - return Role.valueOf(roleStr); - } catch (IllegalArgumentException e) { - throw new JwtException("JWT의 role 클레임이 잘못되었습니다: " + roleStr); - } - } - - // 토큰 유효성 검증 + // 토큰 유효성 검사 public boolean validationToken(String token) { try { Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token); + log.info("Token validation success"); return true; } catch (JwtException | IllegalArgumentException e) { + log.error("Token validation failed: {}", e.getMessage()); return false; } } - // Authentication 객체 생성 + // Authentication 생성 public UsernamePasswordAuthenticationToken getAuthentication(String token) { Claims claims = parseClaims(token); + String userId = claims.get("uid", String.class); String roleStr = claims.get("role", String.class); - if(roleStr == null) { + if (roleStr == null) { throw new JwtException("JWT에 role 클레임이 없습니다."); } @@ -88,23 +61,49 @@ public UsernamePasswordAuthenticationToken getAuthentication(String token) { throw new JwtException("JWT의 role 클레임이 잘못되었습니다.: " + roleStr); } - Collection authorities = - List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); // "ROLE_TEAM_MEMBER" + if (userId == null) { + throw new JwtException("JWT에 userId(uid)가 없습니다."); + } + + // DB에서 다시 유저를 불러오기 (CustomUserDetailsService 사용) + UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId); + + log.debug("인증 객체 생성 완료"); + return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - UserDetails userDetails = customUserDetailsService.loadUserByUsername(claims.getSubject()); - return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); } // Claims 파싱 private Claims parseClaims(String token) { - try{ + try { return Jwts.parserBuilder() .setSigningKey(secretKey) .build() .parseClaimsJws(token) .getBody(); - } catch(ExpiredJwtException e) { + } catch (ExpiredJwtException e) { return e.getClaims(); } } -} \ No newline at end of file + + public UUID getUserIdFromToken(String token) { + Claims claims = parseClaims(token); + String userIdStr = claims.get("uid", String.class); + + // uid 클레임이 없을 경우 subject로 대체 (RefreshToken 호환) + if (userIdStr == null || userIdStr.isBlank()) { + userIdStr = claims.getSubject(); + } + + // 여전히 없거나 비어 있으면 명시적 예외 처리 + if (userIdStr == null || userIdStr.isBlank()) { + throw new JwtException("JWT에 userId(uid/subject)가 없습니다."); + } + + try { + return UUID.fromString(userIdStr); + } catch (IllegalArgumentException | NullPointerException e) { + throw new JwtException("잘못된 userId 형식의 JWT입니다."); + } + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtProvider.java b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtProvider.java index 191d0b9f..e71a09f7 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtProvider.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtProvider.java @@ -35,12 +35,13 @@ public void init() { } // JWT 토큰 생성 - public String createToken(UUID userId, Role role) { + public String createToken(UUID userId, Role role, String email) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + accessTokenValidityInMillis); return Jwts.builder() - .setSubject(userId.toString()) + .setSubject(email) + .claim("uid", userId.toString()) .claim("role", role.name()) .setIssuedAt(now) .setExpiration(expiryDate) @@ -83,6 +84,8 @@ public String getRoleFromToken(String token) { return claims.get("role", String.class); } + + // 토큰 유효성 검증 public boolean validationToken(String token) { try { diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java index 2a7b2ab6..c5069d7d 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java @@ -38,6 +38,11 @@ public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority(role.name())); } + @Override + public String getPassword() { + return password; + } + @Override public String getUsername() { return this.email; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java index d07187a2..93ba2658 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java @@ -20,8 +20,14 @@ public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override - public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User findUser = userRepository.findById(UUID.fromString(email)).orElseThrow( + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + UUID uuid; + try { + uuid = UUID.fromString(userId); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN); + } + User findUser = userRepository.findById(uuid).orElseThrow( () -> new CustomException(ErrorCode.USER_NOT_FOUND) ); diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java index 8a89fae1..6ba65d16 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java @@ -25,20 +25,24 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; + + @Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final Optional jwtParser; + private final JwtParser jwtParser; private final AntPathMatcher pathMatcher = new AntPathMatcher(); private static final List EXCLUDE_PATTERNS = List.of( - "/user/signup", - "/auth/login", - "/auth/login/kakao", - "/auth/login/google", - "/auth/login/github", + "/api/auth/signup", + "/api/auth/login", + "/api/auth/login/kakao", + "/api/auth/login/google", + "/api/auth/login/github", + "/api/auth/oauth/**", +// "/api/auth/refresh", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui/index.html", @@ -47,51 +51,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { ); @Override - protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, + protected void doFilterInternal(@NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { + String requestURI = request.getRequestURI(); - if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + // 인증 제외 경로 + if (shouldNotFilter(request)) { filterChain.doFilter(request, response); return; } - // 테스트 환경에서는 JwtParser가 없어도 그냥 통과시킴 - if (jwtParser.isEmpty()) { - log.debug("JwtParser not found (likely test environment) → skipping JWT validation"); + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } try { String token = resolveToken(request); - JwtParser parser = jwtParser.get(); - if (token != null && parser.validationToken(token)) { - UsernamePasswordAuthenticationToken authentication = parser.getAuthentication(token); + if (token != null && jwtParser.validationToken(token)) { + UsernamePasswordAuthenticationToken authentication = jwtParser.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); - - log.debug("Valid JWT token found for request: {}", request.getRequestURI()); - - filterChain.doFilter(request, response); + log.info("SecurityContext에 인증 저장됨: {}", authentication.getName()); } else { - throw new JwtException("Token is null or invalid"); + log.warn("토큰이 없거나 유효하지 않음"); } } catch (JwtException e) { + log.error("JWT validation failed: {}", e.getMessage(), e); ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_ACCESS_TOKEN); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(toJson(errorResponse)); + return; // 예외 시 여기서 중단 } + + // 필터 체인은 항상 마지막에 한 번만 호출 + filterChain.doFilter(request, response); } + @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - return EXCLUDE_PATTERNS.stream() + + boolean excluded = EXCLUDE_PATTERNS.stream() .anyMatch(pattern -> pathMatcher.match(pattern, path)); + + // 어떤 요청이 필터 예외로 분류됐는지 콘솔에 표시 + log.info("JwtFilter check path: {} → excluded={}", path, excluded); + + return excluded; } private String resolveToken(HttpServletRequest request) { diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index ca273157..b094a362 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -19,21 +19,23 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/user") +@RequestMapping("/api/user") @Slf4j public class UserController { private final UserService userService; - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { - SignupResponse response = userService.signUp(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } + @GetMapping("/details") - public ResponseEntity getUserInfo(@AuthenticationPrincipal CustomUserDetails user) { - log.info("email : " + user.getEmail() + " 권한: " + user.getAuthorities()); + public ResponseEntity getUserInfo(@AuthenticationPrincipal CustomUserDetails user) { + if (user == null) { + log.warn("인증되지 않은 사용자 접근 시도"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("message", "인증이 필요합니다.")); + } + + log.info("email: {} 권한: {}", user.getUsername(), user.getAuthorities()); UserInfoResponse response = new UserInfoResponse( user.getUserId(), diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java index 770cd080..cce03089 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java @@ -34,6 +34,7 @@ public class UserServiceImpl implements UserService { @Override @Transactional public SignupResponse signUp(SignupRequest dto) { + log.debug("[SIGNUP] request: {}", dto.getEmail()); if (userRepository.existsByEmail(dto.getEmail())) { throw new CustomException(ErrorCode.DUPLICATE_EMAIL); } diff --git a/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java b/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java index 03a3b10c..a72db8f7 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/controller/OauthLoginControllerTest.java @@ -1,6 +1,8 @@ package org.sejongisc.backend.auth.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.jsonwebtoken.JwtException; import jakarta.servlet.http.HttpSession; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -20,27 +22,35 @@ import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.user.service.UserService; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.LocalDateTime; +import java.util.HashMap; import java.util.Map; import java.util.UUID; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.*; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @ExtendWith(MockitoExtension.class) class OauthLoginControllerTest { - // 공통 OAuth 서비스 @Mock Oauth2Service googleService; @Mock Oauth2Service kakaoService; @Mock Oauth2Service githubService; @@ -73,18 +83,33 @@ void setUp() { oauthStateService ); - objectMapper = new ObjectMapper(); + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); mockMvc = MockMvcBuilders.standaloneSetup(oauthLoginController) - .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .setValidator(validator) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(), new TestValidationHandler()) .build(); } + @RestControllerAdvice + static class TestValidationHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handle(MethodArgumentNotValidException ex) { + Map body = new HashMap<>(); + body.put("error", "validation"); + body.put("message", "입력값 검증 실패"); + return ResponseEntity.badRequest().body(body); // ✅ 강제로 400 반환 + } + } - - // ✅ 일반 로그인 테스트 + // 일반 로그인 성공 @Test - @DisplayName("POST /auth/login - 로그인 성공 시 200 OK") + @DisplayName("POST /api/auth/login - 로그인 성공 시 200 OK") void login_success() throws Exception { LoginRequest req = new LoginRequest("hong@example.com", "Password123!"); LoginResponse resp = LoginResponse.builder() @@ -99,7 +124,7 @@ void login_success() throws Exception { when(loginService.login(any(LoginRequest.class))).thenReturn(resp); - mockMvc.perform(post("/auth/login") + mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isOk()) @@ -110,39 +135,39 @@ void login_success() throws Exception { .andExpect(jsonPath("$.point", is(100))); } + // 존재하지 않는 사용자 @Test - @DisplayName("POST /auth/login - 존재하지 않는 사용자면 404 반환") + @DisplayName("POST /api/auth/login - 존재하지 않는 사용자면 404 반환") void login_userNotFound() throws Exception { - LoginRequest req = new LoginRequest("notfound@example.com", "Password123!"); - when(loginService.login(any(LoginRequest.class))) .thenThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); - mockMvc.perform(post("/auth/login") + LoginRequest req = new LoginRequest("notfound@example.com", "Password123!"); + mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.errorCode", is("USER_NOT_FOUND"))); } + // 비밀번호 오류 @Test - @DisplayName("POST /auth/login - 비밀번호 틀리면 401 반환") + @DisplayName("POST /api/auth/login - 비밀번호 틀리면 401 반환") void login_wrongPassword() throws Exception { - LoginRequest req = new LoginRequest("hong@example.com", "WrongPassword!"); - when(loginService.login(any(LoginRequest.class))) .thenThrow(new CustomException(ErrorCode.UNAUTHORIZED)); - mockMvc.perform(post("/auth/login") + LoginRequest req = new LoginRequest("hong@example.com", "WrongPassword!"); + mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.errorCode", is("UNAUTHORIZED"))); } - // ✅ GOOGLE 로그인 테스트 + // 구글 로그인 성공 @Test - @DisplayName("POST /auth/login/GOOGLE - 구글 로그인 성공") + @DisplayName("POST /api/auth/login/GOOGLE - 구글 로그인 성공") void googleLogin_success() throws Exception { GoogleTokenResponse tokenResponse = new GoogleTokenResponse(); tokenResponse.setAccessToken("mock-google-access-token"); @@ -150,9 +175,8 @@ void googleLogin_success() throws Exception { GoogleUserInfoResponse userInfo = new GoogleUserInfoResponse(); userInfo.setEmail("google@test.com"); userInfo.setName("구글유저"); - userInfo.setSub("12345"); - User mockUser = User.builder() + User user = User.builder() .userId(UUID.randomUUID()) .name("구글유저") .email("google@test.com") @@ -162,174 +186,211 @@ void googleLogin_success() throws Exception { when(oauthStateService.getStateFromSession(any())).thenReturn("test-state"); when(googleService.getAccessToken("test-code")).thenReturn(tokenResponse); when(googleService.getUserInfo("mock-google-access-token")).thenReturn(userInfo); - when(userService.findOrCreateUser(any())).thenReturn(mockUser); - when(jwtProvider.createToken(mockUser.getUserId(), mockUser.getRole())).thenReturn("mock-jwt-token"); - when(jwtProvider.createRefreshToken(mockUser.getUserId())).thenReturn("mock-refresh-token"); + when(userService.findOrCreateUser(any())).thenReturn(user); + when(jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail())) + .thenReturn("jwt-token"); + when(jwtProvider.createRefreshToken(user.getUserId())).thenReturn("refresh-token"); - mockMvc.perform(post("/auth/login/GOOGLE") + mockMvc.perform(post("/api/auth/login/GOOGLE") .param("code", "test-code") .param("state", "test-state")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("mock-jwt-token")) - .andExpect(jsonPath("$.name").value("구글유저")) - .andExpect(jsonPath("$.role").value("TEAM_MEMBER")); + .andExpect(jsonPath("$.accessToken").value("jwt-token")) + .andExpect(jsonPath("$.name").value("구글유저")); } - // ✅ KAKAO 로그인 테스트 + // OAuth state 불일치 @Test - @DisplayName("POST /auth/login/KAKAO - 카카오 로그인 성공") - void kakaoLogin_success() throws Exception { - KakaoTokenResponse tokenResponse = new KakaoTokenResponse(); - tokenResponse.setAccessToken("mock-kakao-access-token"); - - KakaoUserInfoResponse userInfo = new KakaoUserInfoResponse(); - userInfo.setId(98765L); - - User mockUser = User.builder() - .userId(UUID.randomUUID()) - .name("카카오닉네임") - .role(Role.TEAM_MEMBER) - .build(); - - when(oauthStateService.getStateFromSession(any())).thenReturn("test-state"); - when(kakaoService.getAccessToken("test-code")).thenReturn(tokenResponse); - when(kakaoService.getUserInfo("mock-kakao-access-token")).thenReturn(userInfo); - when(userService.findOrCreateUser(any())).thenReturn(mockUser); - when(jwtProvider.createToken(mockUser.getUserId(), mockUser.getRole())).thenReturn("mock-jwt-token"); - when(jwtProvider.createRefreshToken(mockUser.getUserId())).thenReturn("mock-refresh-token"); + @DisplayName("POST /api/auth/login/KAKAO - state 불일치 시 401 반환") + void oauthLogin_invalidState() throws Exception { + when(oauthStateService.getStateFromSession(any())).thenReturn("expectedState"); - mockMvc.perform(post("/auth/login/KAKAO") + mockMvc.perform(post("/api/auth/login/KAKAO") .param("code", "test-code") - .param("state", "test-state")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("mock-jwt-token")) - .andExpect(jsonPath("$.name").value("카카오닉네임")); + .param("state", "wrong-state")) + .andExpect(status().isUnauthorized()); } - // ✅ GITHUB 로그인 테스트 + // 잘못된 provider @Test - @DisplayName("POST /auth/login/GITHUB - 깃허브 로그인 성공") - void githubLogin_success() throws Exception { - GithubTokenResponse tokenResponse = new GithubTokenResponse(); - tokenResponse.setAccessToken("mock-github-access-token"); - - GithubUserInfoResponse userInfo = new GithubUserInfoResponse(); - userInfo.setName("깃허브유저"); - userInfo.setEmail("gh@test.com"); - - User mockUser = User.builder() - .userId(UUID.randomUUID()) - .name("깃허브유저") - .email("gh@test.com") - .role(Role.TEAM_MEMBER) - .build(); - + @DisplayName("POST /api/auth/login/unknown - 존재하지 않는 provider 요청 시 500") + void oauthLogin_invalidProvider() throws Exception { when(oauthStateService.getStateFromSession(any())).thenReturn("test-state"); - when(githubService.getAccessToken("test-code")).thenReturn(tokenResponse); - when(githubService.getUserInfo("mock-github-access-token")).thenReturn(userInfo); - when(userService.findOrCreateUser(any())).thenReturn(mockUser); - when(jwtProvider.createToken(mockUser.getUserId(), mockUser.getRole())).thenReturn("mock-jwt-token"); - when(jwtProvider.createRefreshToken(mockUser.getUserId())).thenReturn("mock-refresh-token"); - mockMvc.perform(post("/auth/login/GITHUB") - .param("code", "test-code") + mockMvc.perform(post("/api/auth/login/unknown") + .param("code", "code") .param("state", "test-state")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.accessToken").value("mock-jwt-token")) - .andExpect(jsonPath("$.name").value("깃허브유저")); + .andExpect(status().is5xxServerError()) + .andExpect(result -> + assertTrue(result.getResolvedException() instanceof IllegalArgumentException)); } - @DisplayName("startOauthLogin - 각 provider별 정상 응답 확인") + // startOauthLogin - 모든 provider 테스트 @Test - void startOauthLogin_success() throws Exception { - // given + @DisplayName("GET /api/auth/oauth/{provider}/init - OAuth URL 생성 확인") + void startOauthLogin_allProviders() throws Exception { when(oauthStateService.generateAndSaveState(any(HttpSession.class))).thenReturn("state123"); - // when & then - mockMvc.perform(get("/auth/oauth/google/init")) + mockMvc.perform(get("/api/auth/oauth/google/init")) .andExpect(status().isOk()) .andExpect(content().string(containsString("accounts.google.com"))); - mockMvc.perform(get("/auth/oauth/kakao/init")) + mockMvc.perform(get("/api/auth/oauth/kakao/init")) .andExpect(status().isOk()) .andExpect(content().string(containsString("kauth.kakao.com"))); - mockMvc.perform(get("/auth/oauth/github/init")) + mockMvc.perform(get("/api/auth/oauth/github/init")) .andExpect(status().isOk()) .andExpect(content().string(containsString("github.com"))); + + mockMvc.perform(get("/api/auth/oauth/unknown/init")) + .andExpect(status().is5xxServerError()); } - @DisplayName("startOauthLogin - 존재하지 않는 provider 요청 시 IllegalArgumentException 발생") + // 로그아웃 정상 @Test - void startOauthLogin_invalidProvider() throws Exception { - when(oauthStateService.generateAndSaveState(any(HttpSession.class))).thenReturn("state123"); + @DisplayName("POST /api/auth/logout - 정상 로그아웃 시 200 OK") + void logout_success() throws Exception { + String token = "fake.jwt.token"; + mockMvc.perform(post("/api/auth/logout") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 성공")); - mockMvc.perform(get("/auth/oauth/unknown/init")) - .andExpect(status().is5xxServerError()); + verify(loginService, times(1)).logout(token); } - @DisplayName("OauthLogin - 저장된 state와 불일치 시 401 반환") + // Authorization 헤더 누락 @Test - void oauthLogin_invalidState() throws Exception { - when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("expected_state"); - - mockMvc.perform(post("/auth/login/google") - .param("code", "valid_code") - .param("state", "wrong_state")) - .andExpect(status().isUnauthorized()); + @DisplayName("POST /api/auth/logout - Authorization 헤더 누락 시 400") + void logout_missingHeader() throws Exception { + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("잘못된 Authorization 헤더 형식입니다.")); + verify(loginService, never()).logout(anyString()); } - @DisplayName("OauthLogin - provider가 잘못된 경우 500 반환") + // 잘못된 토큰 (JwtException) @Test - void oauthLogin_invalidProvider() throws Exception { - when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("valid_state"); + @DisplayName("POST /api/auth/logout - 잘못된 토큰이어도 200 OK 응답 (멱등성 보장)") + void logout_invalidToken() throws Exception { + doThrow(new JwtException("Invalid Token")).when(loginService).logout(anyString()); - mockMvc.perform(post("/auth/login/unknown") - .param("code", "valid_code") - .param("state", "valid_state")) - .andExpect(status().is5xxServerError()); + mockMvc.perform(post("/api/auth/logout") + .header(HttpHeaders.AUTHORIZATION, "Bearer invalid.token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("로그아웃 성공")); } + // 회원가입 성공 @Test - @DisplayName("POST /auth/login/{provider} - 지원하지 않는 provider 요청 시 500 반환") - void oauthLogin_unknownProvider() throws Exception { - when(oauthStateService.getStateFromSession(any(HttpSession.class))).thenReturn("valid_state"); + @DisplayName("POST /api/auth/signup - 201 Created & 응답 DTO 반환") + void signup_success() throws Exception { + SignupRequest req = SignupRequest.builder() + .name("홍길동") + .email("hong@example.com") + .password("Password123!") + .role(Role.TEAM_MEMBER) + .phoneNumber("01012345678") + .build(); - mockMvc.perform(post("/auth/login/UNSUPPORTED") - .param("code", "valid_code") - .param("state", "valid_state")) - .andExpect(status().is5xxServerError()) - .andExpect(result -> - assertTrue(result.getResolvedException() instanceof IllegalArgumentException)) - .andExpect(result -> - assertTrue(result.getResolvedException().getMessage().contains("Unknown provider"))); + UUID userId = UUID.randomUUID(); + User entity = User.builder() + .userId(userId) + .name("홍길동") + .email("hong@example.com") + .passwordHash("ENCODED") + .role(Role.TEAM_MEMBER) + .phoneNumber("01012345678") + .build(); + + SignupResponse resp = SignupResponse.from(entity); + when(userService.signUp(any(SignupRequest.class))).thenReturn(resp); + + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId", is(userId.toString()))) + .andExpect(jsonPath("$.name", is("홍길동"))); } + // 회원가입 검증 실패 + @Test + @DisplayName("POST /api/auth/signup - 요청 검증 실패 시 500 (GlobalExceptionHandler 미처리)") + void signup_validation_fail() throws Exception { + String invalidJson = """ + { + "email":"hong@example.com", + "password":"Password123!", + "role":"TEAM_MEMBER" + } + """; + + mockMvc.perform(post("/api/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson)) + .andExpect(status().isInternalServerError()) + .andExpect(result -> + assertTrue(result.getResolvedException() instanceof org.springframework.web.bind.MethodArgumentNotValidException)); + } @Test - @DisplayName("POST /auth/logout - 정상 로그아웃 시 200 반환") - void logout_success() throws Exception { - String token = "fake.jwt.token"; - String bearerToken = "Bearer " + token; + @DisplayName("POST /api/auth/login/KAKAO - 토큰 또는 유저정보 null일 때도 정상 처리") + void kakaoLogin_partialCoverage() throws Exception { + // given + KakaoTokenResponse tokenResponse = new KakaoTokenResponse(); + tokenResponse.setAccessToken("mock-token"); - mockMvc.perform(post("/auth/logout") - .header("Authorization", bearerToken)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("로그아웃 성공")); + when(oauthStateService.getStateFromSession(any())).thenReturn("state123"); + when(kakaoService.getAccessToken(anyString())).thenReturn(tokenResponse); + when(kakaoService.getUserInfo(anyString())).thenReturn(null); // info null - verify(loginService, times(1)).logout(token); + when(userService.findOrCreateUser(any())).thenReturn( + User.builder().userId(UUID.randomUUID()).name("NullInfoUser").role(Role.TEAM_MEMBER).build() + ); + + when(jwtProvider.createToken(any(), any(), any())).thenReturn("access-token"); + when(jwtProvider.createRefreshToken(any())).thenReturn("refresh-token"); + + mockMvc.perform(post("/api/auth/login/KAKAO") + .param("code", "dummy-code") + .param("state", "state123")) + .andExpect(status().isOk()); } @Test - @DisplayName("POST /auth/logout - Authorization 헤더가 없으면 400 반환") - void logout_missingHeader() throws Exception { - mockMvc.perform(post("/auth/logout")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("잘못된 Authorization 헤더 형식입니다.")); + @DisplayName("POST /api/auth/login/GITHUB - Github 토큰 정상이나 유저 정보 누락 시도 커버") + void githubLogin_partialCoverage() throws Exception { + GithubTokenResponse tokenResponse = new GithubTokenResponse(); + tokenResponse.setAccessToken("mock-gh-token"); + + when(oauthStateService.getStateFromSession(any())).thenReturn("state123"); + when(githubService.getAccessToken(anyString())).thenReturn(tokenResponse); + when(githubService.getUserInfo(anyString())).thenReturn(null); // info null + when(userService.findOrCreateUser(any())).thenReturn( + User.builder().userId(UUID.randomUUID()).name("GH-NullUser").role(Role.TEAM_MEMBER).build() + ); + + when(jwtProvider.createToken(any(), any(), any())).thenReturn("gh-token"); + when(jwtProvider.createRefreshToken(any())).thenReturn("gh-refresh"); - verify(loginService, times(0)).logout(anyString()); + mockMvc.perform(post("/api/auth/login/GITHUB") + .param("code", "dummy-code") + .param("state", "state123")) + .andExpect(status().isOk()); } + @Test + @DisplayName("POST /api/auth/login/UNKNOWN - Unknown provider 커버") + void oauthLogin_unknownProvider_branch() throws Exception { + when(oauthStateService.getStateFromSession(any())).thenReturn("state123"); + + mockMvc.perform(post("/api/auth/login/UNDEFINED") + .param("code", "dummy") + .param("state", "state123")) + .andExpect(status().is5xxServerError()); + } } diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java index 20c5cca2..318de394 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java @@ -76,7 +76,7 @@ void login_success() { given(userRepository.findUserByEmail("test@example.com")) .willReturn(Optional.of(user)); given(passwordEncoder.matches(rawPassword, encodedPassword)).willReturn(true); - given(jwtProvider.createToken(any(UUID.class), any(Role.class))) + given(jwtProvider.createToken(any(UUID.class), any(Role.class), anyString())) .willReturn("mocked-jwt-token"); // when diff --git a/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java b/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java index 3b110b50..5c304214 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java @@ -1,5 +1,4 @@ package org.sejongisc.backend.user.controller; - import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.BeforeEach; @@ -61,66 +60,10 @@ void setUp() { .build(); } - @Test - @DisplayName("POST /user/signup - 201 Created & 응답 DTO 반환") - void signup_success() throws Exception { - SignupRequest req = SignupRequest.builder() - .name("홍길동") - .email("hong@example.com") - .password("Password123!") - .role(Role.TEAM_MEMBER) - .phoneNumber("01012345678") - .build(); - - UUID userId = UUID.randomUUID(); - LocalDateTime now = LocalDateTime.now(); - - User entity = User.builder() - .userId(userId) - .name("홍길동") - .email("hong@example.com") - .passwordHash("ENCODED") - .role(Role.TEAM_MEMBER) - .phoneNumber("01012345678") - .build(); - entity.setCreatedDate(now); - entity.setUpdatedDate(now); - - SignupResponse resp = SignupResponse.from(entity); - - when(userService.signUp(any(SignupRequest.class))).thenReturn(resp); - - mockMvc.perform(post("/user/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(req))) - .andExpect(status().isCreated()) - .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.userId", is(userId.toString()))) - .andExpect(jsonPath("$.name", is("홍길동"))) - .andExpect(jsonPath("$.email", is("hong@example.com"))) - .andExpect(jsonPath("$.role", is("TEAM_MEMBER"))); - } - - @Test - @DisplayName("POST /user/signup - 요청 검증 실패 시 400") - void signup_validation_fail() throws Exception { - String invalidJson = """ - { - "email":"hong@example.com", - "password":"Password123!", - "role":"TEAM_MEMBER", - "phoneNumber":"01012345678" - } - """; - mockMvc.perform(post("/user/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(invalidJson)) - .andExpect(status().isBadRequest()); - } @Test - @DisplayName("GET /user/details - 로그인 사용자 정보 반환") + @DisplayName("GET /api/user/details - 로그인 사용자 정보 반환") void getUserDetails_success() throws Exception { UUID userId = UUID.randomUUID(); User userEntity = User.builder() @@ -140,7 +83,7 @@ void getUserDetails_success() throws Exception { // SecurityContext 직접 주입 SecurityContextHolder.getContext().setAuthentication(auth); - mockMvc.perform(get("/user/details")) + mockMvc.perform(get("/api/user/details")) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(userId.toString())) .andExpect(jsonPath("$.name").value("홍길동"))