diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 4a9ed7da..2eae4611 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -1,8 +1,8 @@ package com.ai.lawyer.domain.member.controller; -import com.ai.lawyer.domain.auth.dto.OAuth2LoginResponse; import com.ai.lawyer.domain.member.dto.*; import com.ai.lawyer.domain.member.service.MemberService; +import com.ai.lawyer.global.oauth.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -64,51 +64,19 @@ public ResponseEntity login(@Valid @RequestBody MemberLoginReque } @GetMapping("/oauth2/kakao") - @Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다.") + @Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다. 프론트엔드 페이지로 리다이렉트됩니다.") public void kakaoLogin(HttpServletResponse response) throws Exception { log.info("카카오 로그인 요청"); response.sendRedirect("/oauth2/authorization/kakao"); } @GetMapping("/oauth2/naver") - @Operation(summary = "12. 네이버 로그인", description = "네이버 OAuth2 로그인을 시작합니다.") + @Operation(summary = "12. 네이버 로그인", description = "네이버 OAuth2 로그인을 시작합니다. 프론트엔드 페이지로 리다이렉트됩니다.") public void naverLogin(HttpServletResponse response) throws Exception { log.info("네이버 로그인 요청"); response.sendRedirect("/oauth2/authorization/naver"); } - @GetMapping("/oauth2/callback/success") - @Operation(summary = "14. OAuth2 로그인 성공 콜백", description = "OAuth2 로그인 성공 시 호출되는 콜백 엔드포인트입니다.") - @ApiResponses({ - @ApiResponse(responseCode = "200", description = "로그인 성공"), - }) - public ResponseEntity oauth2LoginSuccess() { - log.info("OAuth2 로그인 성공 콜백"); - - OAuth2LoginResponse response = OAuth2LoginResponse.builder() - .success(true) - .message("소셜 로그인에 성공했습니다.") - .build(); - - return ResponseEntity.ok(response); - } - - @GetMapping("/oauth2/callback/failure") - @Operation(summary = "15. OAuth2 로그인 실패 콜백", description = "OAuth2 로그인 실패 시 호출되는 콜백 엔드포인트입니다.") - @ApiResponses({ - @ApiResponse(responseCode = "401", description = "로그인 실패"), - }) - public ResponseEntity oauth2LoginFailure( - @RequestParam(required = false) String error) { - log.error("OAuth2 로그인 실패: {}", error); - - OAuth2LoginResponse response = OAuth2LoginResponse.builder() - .success(false) - .message("소셜 로그인에 실패했습니다: " + (error != null ? error : "알 수 없는 오류")) - .build(); - - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); - } @PostMapping("/oauth2/test") @Operation(summary = "13. OAuth2 로그인 테스트 (개발용)", description = "OAuth2 플로우 없이 소셜 로그인 결과를 시뮬레이션합니다.") @@ -121,16 +89,42 @@ public ResponseEntity oauth2LoginTest( } @PostMapping("/logout") - @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") + @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다. 로컬 로그인과 소셜 로그인 모두 지원합니다.") public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { - if (authentication != null && authentication.getDetails() != null) { - String loginId = (String) authentication.getDetails(); - memberService.logout(loginId, response); - log.info("로그아웃 완료: {}", loginId); + String loginId = null; + + if (authentication != null && authentication.getPrincipal() != null) { + Object principal = authentication.getPrincipal(); + + // JWT 토큰 기반 인증 (로컬 로그인 & 소셜 로그인 모두) + if (principal instanceof Long memberId) { + // memberId로 조회 + loginId = memberService.getLoginIdByMemberId(memberId); + log.info("memberId로 로그아웃: memberId={}, loginId={}", memberId, loginId); + } + // PrincipalDetails (OAuth2 또는 로컬 로그인) + else if (principal instanceof PrincipalDetails principalDetails) { + com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); + loginId = member.getLoginId(); + log.info("PrincipalDetails로 로그아웃: loginId={}, type={}", + loginId, member.getClass().getSimpleName()); + } + // authentication.getDetails() 사용 (기존 방식) + else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("Details로 로그아웃: loginId={}", loginId); + } + } + + // 로그아웃 처리 (Redis에서 토큰 삭제 + 쿠키 삭제) + memberService.logout(loginId != null ? loginId : "", response); + + if (loginId != null) { + log.info("로그아웃 완료: loginId={}", loginId); } else { - memberService.logout("", response); - log.info("인증 정보 없이 로그아웃 완료"); + log.info("인증 정보 없이 로그아웃 완료 (쿠키만 삭제)"); } + return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index ca450864..324eccf7 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -175,6 +175,22 @@ public MemberResponse getMemberById(Long memberId) { return MemberResponse.from(member); } + public String getLoginIdByMemberId(Long memberId) { + // Member 또는 OAuth2Member 조회 + com.ai.lawyer.domain.member.entity.MemberAdapter member = memberRepository.findById(memberId).orElse(null); + + if (member == null && oauth2MemberRepository != null) { + member = oauth2MemberRepository.findById(memberId).orElse(null); + } + + if (member == null) { + log.warn("회원을 찾을 수 없습니다: memberId={}", memberId); + return null; + } + + return member.getLoginId(); + } + public void sendCodeToEmailByLoginId(String loginId) { Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND_BY_LOGIN_ID)); diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index bb31c6a4..5e98fbc3 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -3,8 +3,11 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; +import java.time.Duration; + @Component public class CookieUtil { @@ -15,13 +18,14 @@ public class CookieUtil { // 쿠키 만료 시간 상수 (초 단위) private static final int MINUTES_PER_HOUR = 60; private static final int HOURS_PER_DAY = 24; - private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * MINUTES_PER_HOUR; // 5분 + private static final int ACCESS_TOKEN_EXPIRE_TIME = 5 * 60; // 5분 (300초) private static final int REFRESH_TOKEN_EXPIRE_TIME = 7 * HOURS_PER_DAY * MINUTES_PER_HOUR * 60; // 7일 // 쿠키 보안 설정 상수 private static final boolean HTTP_ONLY = true; - private static final boolean SECURE_IN_PRODUCTION = false; // 운영환경에서는 true로 변경 (HTTPS) + private static final boolean SECURE_IN_PRODUCTION = true; // 운영환경에서는 true로 변경 (HTTPS) private static final String COOKIE_PATH = "/"; + private static final String SAME_SITE = "None"; // None, Lax, Strict 중 선택 private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { @@ -30,13 +34,13 @@ public void setTokenCookies(HttpServletResponse response, String accessToken, St } public void setAccessTokenCookie(HttpServletResponse response, String accessToken) { - Cookie accessCookie = createCookie(ACCESS_TOKEN_NAME, accessToken, ACCESS_TOKEN_EXPIRE_TIME); - response.addCookie(accessCookie); + ResponseCookie cookie = createResponseCookie(ACCESS_TOKEN_NAME, accessToken, ACCESS_TOKEN_EXPIRE_TIME); + response.addHeader("Set-Cookie", cookie.toString()); } public void setRefreshTokenCookie(HttpServletResponse response, String refreshToken) { - Cookie refreshCookie = createCookie(REFRESH_TOKEN_NAME, refreshToken, REFRESH_TOKEN_EXPIRE_TIME); - response.addCookie(refreshCookie); + ResponseCookie cookie = createResponseCookie(REFRESH_TOKEN_NAME, refreshToken, REFRESH_TOKEN_EXPIRE_TIME); + response.addHeader("Set-Cookie", cookie.toString()); } public void clearTokenCookies(HttpServletResponse response) { @@ -45,23 +49,24 @@ public void clearTokenCookies(HttpServletResponse response) { } /** - * 쿠키를 생성합니다. + * ResponseCookie를 생성합니다 (SameSite 지원). */ - private Cookie createCookie(String name, String value, int maxAge) { - Cookie cookie = new Cookie(name, value); - cookie.setHttpOnly(HTTP_ONLY); - cookie.setSecure(SECURE_IN_PRODUCTION); - cookie.setPath(COOKIE_PATH); - cookie.setMaxAge(maxAge); - return cookie; + private ResponseCookie createResponseCookie(String name, String value, int maxAge) { + return ResponseCookie.from(name, value) + .httpOnly(HTTP_ONLY) + .secure(SECURE_IN_PRODUCTION) + .path(COOKIE_PATH) + .maxAge(Duration.ofSeconds(maxAge)) + .sameSite(SAME_SITE) + .build(); } /** * 쿠키를 삭제합니다 (MaxAge를 0으로 설정). */ private void clearCookie(HttpServletResponse response, String cookieName) { - Cookie cookie = createCookie(cookieName, null, COOKIE_EXPIRE_IMMEDIATELY); - response.addCookie(cookie); + ResponseCookie cookie = createResponseCookie(cookieName, "", COOKIE_EXPIRE_IMMEDIATELY); + response.addHeader("Set-Cookie", cookie.toString()); } public String getAccessTokenFromCookies(HttpServletRequest request) { diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index 305f4733..0d26438c 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -80,6 +80,14 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable return; } + // OAuth2 관련 경로는 JWT 필터 스킵 + if (request != null && shouldSkipFilter(request)) { + if (filterChain != null) { + filterChain.doFilter(request, response); + } + return; + } + if (request != null && response != null) { try { processAuthentication(request, response); @@ -94,6 +102,16 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable } } + /** + * JWT 필터를 스킵해야 하는 경로인지 확인합니다. + */ + private boolean shouldSkipFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/oauth2/") + || path.startsWith("/login/oauth2/") + || path.startsWith("/api/auth/oauth2/"); + } + /** * 인증 프로세스를 처리합니다. */ diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java index cd9f7dde..eb0c8d6d 100644 --- a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java @@ -17,7 +17,7 @@ @Component public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { - @Value("${custom.oauth2.failure-url:http://localhost:8080/api/auth/oauth2/callback/failure}") + @Value("${custom.oauth2.failure-url}") private String failureUrl; @Override @@ -29,12 +29,13 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류"; String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); + // 프론트엔드 실패 페이지로 리다이렉트 String targetUrl = UriComponentsBuilder.fromUriString(failureUrl) .queryParam("error", encodedError) - .build(true) // true로 설정하여 이미 인코딩된 값을 사용 + .build(true) .toUriString(); + log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl); - log.info("OAuth2 로그인 실패, 백엔드 콜백으로 리다이렉트: {}", targetUrl); getRedirectStrategy().sendRedirect(request, response, targetUrl); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java index 9799a8a0..0c6db6d4 100644 --- a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java @@ -10,7 +10,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; @@ -22,7 +21,7 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final TokenProvider tokenProvider; private final CookieUtil cookieUtil; - @Value("${custom.oauth2.redirect-url:http://localhost:8080/api/auth/oauth2/callback/success}") + @Value("${custom.oauth2.redirect-url}") private String redirectUrl; @Override @@ -41,12 +40,11 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 쿠키에 토큰 설정 cookieUtil.setTokenCookies(response, accessToken, refreshToken); - // 백엔드 콜백 엔드포인트로 리다이렉트 - String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) - .build() - .toUriString(); + log.info("JWT 토큰 생성 완료 및 쿠키 설정 완료"); - log.info("OAuth2 로그인 완료, 백엔드 콜백으로 리다이렉트: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + // 프론트엔드 성공 페이지로 리다이렉트 + log.info("OAuth2 로그인 완료, 프론트엔드 성공 페이지로 리다이렉트: {}", redirectUrl); + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); } } diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index adb21a7a..ba4cd135 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -4,6 +4,7 @@ import com.ai.lawyer.global.oauth.CustomOAuth2UserService; import com.ai.lawyer.global.oauth.OAuth2FailureHandler; import com.ai.lawyer.global.oauth.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -20,10 +21,17 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; +import java.util.Arrays; +/** + * Spring Security 설정 + * - JWT 기반 인증 + * - OAuth2 소셜 로그인 (카카오, 네이버) + * - CORS 설정 + */ @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @@ -34,67 +42,76 @@ public class SecurityConfig { @Value("${custom.cors.allowed-origins:http://localhost:3000}") private String allowedOrigins; - public SecurityConfig( - JwtAuthenticationFilter jwtAuthenticationFilter, - @org.springframework.beans.factory.annotation.Autowired(required = false) CustomOAuth2UserService customOAuth2UserService, - @org.springframework.beans.factory.annotation.Autowired(required = false) OAuth2SuccessHandler oAuth2SuccessHandler, - @org.springframework.beans.factory.annotation.Autowired(required = false) OAuth2FailureHandler oAuth2FailureHandler) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.customOAuth2UserService = customOAuth2UserService; - this.oAuth2SuccessHandler = oAuth2SuccessHandler; - this.oAuth2FailureHandler = oAuth2FailureHandler; - } + // 인증 없이 접근 가능한 공개 엔드포인트 + private static final String[] PUBLIC_ENDPOINTS = { + "/api/auth/**", // 회원 인증 (로그인, 회원가입, OAuth2 등) + "/api/public/**", // 공개 API + "/oauth2/**", // OAuth2 인증 시작 + "/login/oauth2/**", // OAuth2 콜백 + "/v3/api-docs/**", // Swagger API 문서 + "/swagger-ui/**", // Swagger UI + "/swagger-ui.html", // Swagger UI HTML + "/api/posts/**", // 게시글 (공개) + "/api/precedent/**", // 판례 (공개) + "/api/law/**", // 법령 (공개) + "/api/law-word/**", // 법률 용어 (공개) + "/api/chat/**", // 챗봇 (공개) + "/h2-console/**" // H2 콘솔 (개발용) + }; + + // CORS 허용 메서드 + private static final String[] ALLOWED_METHODS = { + "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" + }; + + // CORS 허용 헤더 + private static final String[] ALLOWED_HEADERS = { + "Authorization", "Content-Type", "Accept", "X-Requested-With" + }; + + // 인증 실패 시 반환할 JSON 메시지 + private static final String UNAUTHORIZED_JSON = + "{\"error\":\"Unauthorized\",\"message\":\"인증이 필요합니다.\"}"; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http + return http + // 기본 보안 설정 .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) + + // 세션 정책: OAuth2 콜백을 위해 필요 시 생성 .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .headers(headers -> headers - .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) - ) + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + + // H2 콘솔을 위한 frameOptions 설정 + .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + + // 접근 권한 설정 .authorizeHttpRequests(authorize -> authorize - .requestMatchers( - "/api/auth/login", - "/api/auth/signup", - "/api/auth/refresh", - "/api/auth/sendEmail", - "/api/auth/verifyEmail", - "/api/auth/passwordReset", - "/api/auth/oauth2/**", - "/api/public/**", - "/oauth2/**", - "/login/oauth2/**").permitAll() - .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() - .requestMatchers("/api/posts/**").permitAll() - .requestMatchers("/api/precedent/**").permitAll() - .requestMatchers("/api/law/**").permitAll() - .requestMatchers("/api/law-word/**").permitAll() - .requestMatchers("/h2-console/**").permitAll() - .requestMatchers("/api/chat/**").permitAll() - .anyRequest().authenticated() - ); - - // OAuth2 로그인 설정 (빈이 있을 때만) - if (customOAuth2UserService != null && oAuth2SuccessHandler != null && oAuth2FailureHandler != null) { - http.oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo - .userService(customOAuth2UserService) - ) - .successHandler(oAuth2SuccessHandler) - .failureHandler(oAuth2FailureHandler) - ); - } - - // JWT 필터 추가 - http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); + .requestMatchers(PUBLIC_ENDPOINTS).permitAll() + .anyRequest().authenticated()) + + // OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler)) + + // 인증 실패 시 JSON 응답 (HTML 로그인 페이지 대신) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(UNAUTHORIZED_JSON); + })) + + // JWT 필터 추가 (UsernamePasswordAuthenticationFilter 이전) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + .build(); } @Bean @@ -105,13 +122,13 @@ public PasswordEncoder passwordEncoder() { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(allowedOrigins.split(","))); - configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); - configuration.setAllowedHeaders(List.of("Authorization","Content-Type","Accept","X-Requested-With")); + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); + configuration.setAllowedMethods(Arrays.asList(ALLOWED_METHODS)); + configuration.setAllowedHeaders(Arrays.asList(ALLOWED_HEADERS)); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java index 4ed1e125..3f25ba9e 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java @@ -15,6 +15,7 @@ import org.slf4j.LoggerFactory; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -57,30 +58,30 @@ void setTokenCookies_Success() { log.info("쿠키 설정 완료"); // then - log.info("검증: 2개의 쿠키(액세스, 리프레시)가 추가되었는지 확인"); - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response, times(2)).addCookie(cookieCaptor.capture()); + log.info("검증: 2개의 Set-Cookie 헤더가 추가되었는지 확인"); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); - var cookies = cookieCaptor.getAllValues(); - assertThat(cookies).hasSize(2); + var setCookieHeaders = headerCaptor.getAllValues(); + assertThat(setCookieHeaders).hasSize(2); // 액세스 토큰 쿠키 검증 - Cookie accessCookie = cookies.getFirst(); - assertThat(accessCookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); - assertThat(accessCookie.getValue()).isEqualTo(ACCESS_TOKEN); - assertThat(accessCookie.isHttpOnly()).isTrue(); - assertThat(accessCookie.getPath()).isEqualTo("/"); - assertThat(accessCookie.getMaxAge()).isEqualTo(5 * 60); // 5분 - log.info("액세스 토큰 쿠키 검증 완료: name={}, maxAge={}", accessCookie.getName(), accessCookie.getMaxAge()); + String accessCookieHeader = setCookieHeaders.getFirst(); + assertThat(accessCookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); + assertThat(accessCookieHeader).contains("HttpOnly"); + assertThat(accessCookieHeader).contains("Path=/"); + assertThat(accessCookieHeader).contains("Max-Age=300"); // 5분 = 300초 + assertThat(accessCookieHeader).contains("SameSite=None"); + log.info("액세스 토큰 쿠키 검증 완료: {}", accessCookieHeader); // 리프레시 토큰 쿠키 검증 - Cookie refreshCookie = cookies.get(1); - assertThat(refreshCookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); - assertThat(refreshCookie.getValue()).isEqualTo(REFRESH_TOKEN); - assertThat(refreshCookie.isHttpOnly()).isTrue(); - assertThat(refreshCookie.getPath()).isEqualTo("/"); - assertThat(refreshCookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); // 7일 - log.info("리프레시 토큰 쿠키 검증 완료: name={}, maxAge={}", refreshCookie.getName(), refreshCookie.getMaxAge()); + String refreshCookieHeader = setCookieHeaders.get(1); + assertThat(refreshCookieHeader).contains(REFRESH_TOKEN_NAME + "=" + REFRESH_TOKEN); + assertThat(refreshCookieHeader).contains("HttpOnly"); + assertThat(refreshCookieHeader).contains("Path=/"); + assertThat(refreshCookieHeader).contains("Max-Age=604800"); // 7일 = 604800초 + assertThat(refreshCookieHeader).contains("SameSite=None"); + log.info("리프레시 토큰 쿠키 검증 완료: {}", refreshCookieHeader); log.info("=== 토큰 쿠키 설정 테스트 완료 ==="); } @@ -95,14 +96,14 @@ void setAccessTokenCookie_Success() { cookieUtil.setAccessTokenCookie(response, ACCESS_TOKEN); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response).addCookie(cookieCaptor.capture()); - - Cookie cookie = cookieCaptor.getValue(); - assertThat(cookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); - assertThat(cookie.getValue()).isEqualTo(ACCESS_TOKEN); - assertThat(cookie.isHttpOnly()).isTrue(); - assertThat(cookie.getMaxAge()).isEqualTo(5 * 60); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + String cookieHeader = headerCaptor.getValue(); + assertThat(cookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); + assertThat(cookieHeader).contains("HttpOnly"); + assertThat(cookieHeader).contains("Max-Age=300"); + assertThat(cookieHeader).contains("SameSite=None"); log.info("=== 액세스 토큰 단독 쿠키 설정 테스트 완료 ==="); } @@ -116,14 +117,14 @@ void setRefreshTokenCookie_Success() { cookieUtil.setRefreshTokenCookie(response, REFRESH_TOKEN); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response).addCookie(cookieCaptor.capture()); - - Cookie cookie = cookieCaptor.getValue(); - assertThat(cookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); - assertThat(cookie.getValue()).isEqualTo(REFRESH_TOKEN); - assertThat(cookie.isHttpOnly()).isTrue(); - assertThat(cookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + String cookieHeader = headerCaptor.getValue(); + assertThat(cookieHeader).contains(REFRESH_TOKEN_NAME + "=" + REFRESH_TOKEN); + assertThat(cookieHeader).contains("HttpOnly"); + assertThat(cookieHeader).contains("Max-Age=604800"); + assertThat(cookieHeader).contains("SameSite=None"); log.info("=== 리프레시 토큰 단독 쿠키 설정 테스트 완료 ==="); } @@ -232,30 +233,28 @@ void clearTokenCookies_Success() { log.info("쿠키 클리어 완료"); // then - log.info("검증: 2개의 쿠키(액세스, 리프레시)가 삭제용으로 추가되었는지 확인"); - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response, times(2)).addCookie(cookieCaptor.capture()); + log.info("검증: 2개의 Set-Cookie 헤더가 삭제용으로 추가되었는지 확인"); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); - var cookies = cookieCaptor.getAllValues(); - assertThat(cookies).hasSize(2); + var setCookieHeaders = headerCaptor.getAllValues(); + assertThat(setCookieHeaders).hasSize(2); // 액세스 토큰 클리어 검증 - Cookie accessClearCookie = cookies.getFirst(); - assertThat(accessClearCookie.getName()).isEqualTo(ACCESS_TOKEN_NAME); - assertThat(accessClearCookie.getValue()).isNull(); - assertThat(accessClearCookie.getMaxAge()).isEqualTo(0); - assertThat(accessClearCookie.isHttpOnly()).isTrue(); - assertThat(accessClearCookie.getPath()).isEqualTo("/"); - log.info("액세스 토큰 쿠키 클리어 검증 완료: maxAge={}", accessClearCookie.getMaxAge()); + String accessClearHeader = setCookieHeaders.getFirst(); + assertThat(accessClearHeader).contains(ACCESS_TOKEN_NAME + "="); + assertThat(accessClearHeader).contains("Max-Age=0"); + assertThat(accessClearHeader).contains("HttpOnly"); + assertThat(accessClearHeader).contains("Path=/"); + log.info("액세스 토큰 쿠키 클리어 검증 완료: {}", accessClearHeader); // 리프레시 토큰 클리어 검증 - Cookie refreshClearCookie = cookies.get(1); - assertThat(refreshClearCookie.getName()).isEqualTo(REFRESH_TOKEN_NAME); - assertThat(refreshClearCookie.getValue()).isNull(); - assertThat(refreshClearCookie.getMaxAge()).isEqualTo(0); - assertThat(refreshClearCookie.isHttpOnly()).isTrue(); - assertThat(refreshClearCookie.getPath()).isEqualTo("/"); - log.info("리프레시 토큰 쿠키 클리어 검증 완료: maxAge={}", refreshClearCookie.getMaxAge()); + String refreshClearHeader = setCookieHeaders.get(1); + assertThat(refreshClearHeader).contains(REFRESH_TOKEN_NAME + "="); + assertThat(refreshClearHeader).contains("Max-Age=0"); + assertThat(refreshClearHeader).contains("HttpOnly"); + assertThat(refreshClearHeader).contains("Path=/"); + log.info("리프레시 토큰 쿠키 클리어 검증 완료: {}", refreshClearHeader); log.info("=== 토큰 쿠키 클리어 테스트 완료 ==="); } @@ -271,12 +270,12 @@ void cookieHttpOnlyAttribute_Security() { cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response, times(2)).addCookie(cookieCaptor.capture()); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); - cookieCaptor.getAllValues().forEach(cookie -> { - assertThat(cookie.isHttpOnly()).isTrue(); - log.info("쿠키 {}: HttpOnly=true (보안 설정 확인)", cookie.getName()); + headerCaptor.getAllValues().forEach(header -> { + assertThat(header).contains("HttpOnly"); + log.info("Set-Cookie 헤더: HttpOnly 포함 확인 - {}", header); }); log.info("=== HttpOnly 속성 보안 테스트 완료 ==="); @@ -293,12 +292,12 @@ void cookiePathAttribute_Accessibility() { cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response, times(2)).addCookie(cookieCaptor.capture()); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); - cookieCaptor.getAllValues().forEach(cookie -> { - assertThat(cookie.getPath()).isEqualTo("/"); - log.info("쿠키 {}: Path=/ (모든 경로 접근 가능)", cookie.getName()); + headerCaptor.getAllValues().forEach(header -> { + assertThat(header).contains("Path=/"); + log.info("Set-Cookie 헤더: Path=/ 포함 확인 - {}", header); }); log.info("=== Path 속성 테스트 완료 ==="); @@ -316,18 +315,18 @@ void cookieMaxAgeAttribute_ExpiryTime() { cookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); // then - ArgumentCaptor cookieCaptor = ArgumentCaptor.forClass(Cookie.class); - verify(response, times(2)).addCookie(cookieCaptor.capture()); + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); - var cookies = cookieCaptor.getAllValues(); + var setCookieHeaders = headerCaptor.getAllValues(); - Cookie accessCookie = cookies.getFirst(); - assertThat(accessCookie.getMaxAge()).isEqualTo(5 * 60); - log.info("액세스 토큰 만료 시간: {}초 (5분)", accessCookie.getMaxAge()); + String accessHeader = setCookieHeaders.getFirst(); + assertThat(accessHeader).contains("Max-Age=300"); + log.info("액세스 토큰 만료 시간: 300초 (5분)"); - Cookie refreshCookie = cookies.get(1); - assertThat(refreshCookie.getMaxAge()).isEqualTo(7 * 24 * 60 * 60); - log.info("리프레시 토큰 만료 시간: {}초 (7일)", refreshCookie.getMaxAge()); + String refreshHeader = setCookieHeaders.get(1); + assertThat(refreshHeader).contains("Max-Age=604800"); + log.info("리프레시 토큰 만료 시간: 604800초 (7일)"); log.info("=== 토큰 만료 시간 테스트 완료 ==="); } diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java index 9893a9fd..a5236d4e 100644 --- a/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandlerTest.java @@ -52,9 +52,9 @@ class OAuth2SuccessHandlerTest { @BeforeEach void setUp() { - // Redirect URL 설정 + // Redirect URL 설정 (환경변수에서 주입되는 값) ReflectionTestUtils.setField(oauth2SuccessHandler, "redirectUrl", - "http://localhost:8080/api/auth/oauth2/callback/success"); + "http://localhost:3000/oauth/success"); // 카카오 회원 생성 kakaoMember = OAuth2Member.builder() @@ -169,7 +169,7 @@ void onAuthenticationSuccess_RedirectUrl() throws Exception { oauth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); // then - log.info("리다이렉트 URL 검증: http://localhost:8080/api/auth/oauth2/callback/success"); + log.info("리다이렉트 URL 검증: http://localhost:3000/oauth/success"); // 실제 리다이렉트는 내부에서 sendRedirect로 처리되므로, // 토큰 생성 및 쿠키 설정이 정상적으로 완료되었는지만 확인 verify(cookieUtil).setTokenCookies(response, accessToken, refreshToken);