diff --git a/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java b/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java deleted file mode 100644 index 2328eaeb..00000000 --- a/backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ai.lawyer.domain.auth.dto; - -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class OAuth2LoginResponse { - private boolean success; - private String message; -} \ No newline at end of file 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..9ede09f4 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,9 @@ 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.OAuth2LogoutService; +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; @@ -17,6 +18,8 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -25,6 +28,8 @@ public class MemberController { private final MemberService memberService; + private final OAuth2LogoutService oauth2LogoutService; + private final com.ai.lawyer.global.oauth.OAuth2TestPageUtil oauth2TestPageUtil; // --- 상수들: 중복 문자열 리터럴 방지 --- private static final String ANONYMOUS_USER = "anonymousUser"; @@ -32,7 +37,6 @@ public class MemberController { private static final String LOG_JWT_EXTRACT_WARN = "JWT 토큰에서 loginId 추출 중 오류: {}"; private static final String LOG_JWT_EXTRACT_INFO = "JWT 토큰에서 loginId 추출 성공: {}"; private static final String LOG_JWT_EXTRACT_FAIL = "JWT 토큰에서 loginId 추출 실패"; - private static final String LOG_INVALID_AUTH = "인증 정보 없음 또는 인증되지 않음"; // ---------------- API ---------------- @@ -64,51 +68,23 @@ public ResponseEntity login(@Valid @RequestBody MemberLoginReque } @GetMapping("/oauth2/kakao") - @Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다.") - public void kakaoLogin(HttpServletResponse response) throws Exception { - log.info("카카오 로그인 요청"); - response.sendRedirect("/oauth2/authorization/kakao"); + @Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다. mode=backend 파라미터로 백엔드 테스트 모드 사용 가능.") + public void kakaoLogin(@RequestParam(required = false, defaultValue = "frontend") String mode, + HttpServletResponse response) throws Exception { + log.info("카카오 로그인 요청 (mode={})", mode); + String redirectUrl = "/oauth2/authorization/kakao?mode=" + mode; + response.sendRedirect(redirectUrl); } @GetMapping("/oauth2/naver") - @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); + @Operation(summary = "12. 네이버 로그인", description = "네이버 OAuth2 로그인을 시작합니다. mode=backend 파라미터로 백엔드 테스트 모드 사용 가능.") + public void naverLogin(@RequestParam(required = false, defaultValue = "frontend") String mode, + HttpServletResponse response) throws Exception { + log.info("네이버 로그인 요청 (mode={})", mode); + String redirectUrl = "/oauth2/authorization/naver?mode=" + mode; + response.sendRedirect(redirectUrl); } - @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 플로우 없이 소셜 로그인 결과를 시뮬레이션합니다.") @@ -120,47 +96,226 @@ public ResponseEntity oauth2LoginTest( return ResponseEntity.ok(memberResponse); } - @PostMapping("/logout") - @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.") - public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { - if (authentication != null && authentication.getDetails() != null) { - String loginId = (String) authentication.getDetails(); + @GetMapping("/oauth2/test-page") + @Operation(summary = "16. OAuth2 소셜 로그인 테스트 페이지 (개발용)", description = "카카오/네이버 소셜 로그인을 테스트할 수 있는 통합 페이지입니다. 개발/테스트 전용입니다.") + public ResponseEntity oauth2TestPage() { + String html = oauth2TestPageUtil.getTestPageHtml(); + return ResponseEntity.ok() + .header("Content-Type", "text/html; charset=UTF-8") + .body(html); + } + + @GetMapping("/oauth2/success-page") + @Operation(summary = "15. OAuth2 로그인 성공 페이지 (백엔드 테스트용)", description = "프론트엔드 없이 백엔드에서 OAuth2 로그인 결과를 확인할 수 있는 페이지입니다.") + public ResponseEntity oauth2SuccessPage(Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null) { + return ResponseEntity.ok(buildHtmlResponse( + "OAuth2 로그인 실패", + "인증 정보가 없습니다.", + null + )); + } + + Object principal = authentication.getPrincipal(); + String loginId = null; + Long memberId = null; + + if (principal instanceof Long) { + memberId = (Long) principal; + loginId = (String) authentication.getDetails(); + } else if (principal instanceof PrincipalDetails principalDetails) { + com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); + loginId = member.getLoginId(); + memberId = member.getMemberId(); + } + + return ResponseEntity.ok(buildHtmlResponse( + "OAuth2 로그인 성공", + "로그인에 성공했습니다!", + String.format("회원 ID: %d
이메일: %s", memberId, loginId) + )); + } + + private String buildHtmlResponse(String title, String message, String details) { + return oauth2TestPageUtil.getSuccessPageHtml(title, message, details); + } + + @PostMapping("/oauth2/clear") + @Operation(summary = "14. OAuth2 로그인 실패 시 정리", description = "프론트엔드에서 OAuth2 로그인 후 에러 발생 시 토큰을 제거하고 로그아웃합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 제거 및 로그아웃 성공") + }) + public ResponseEntity clearOAuth2(HttpServletRequest request, HttpServletResponse response) { + log.info("OAuth2 로그인 실패 정리 요청"); + + // 쿠키에서 토큰 추출 시도 + String accessToken = extractAccessTokenFromRequest(request); + String loginId = null; + + if (accessToken != null) { + loginId = memberService.extractLoginIdFromToken(accessToken); + log.info("추출된 loginId: {}", loginId); + } + + // OAuth2 제공자 로그아웃 URL 조회 + String oauth2LogoutUrl = oauth2LogoutService.getOAuth2LogoutUrl(loginId); + + // 토큰 삭제 및 쿠키 클리어 + if (loginId != null && !loginId.isEmpty()) { memberService.logout(loginId, response); - log.info("로그아웃 완료: {}", loginId); + log.info("OAuth2 토큰 제거 완료: loginId={}", loginId); } else { + // loginId가 없어도 쿠키는 클리어 memberService.logout("", response); - log.info("인증 정보 없이 로그아웃 완료"); + log.info("OAuth2 쿠키 클리어 완료 (loginId 없음)"); } - return ResponseEntity.ok().build(); + + return ResponseEntity.ok(LogoutResponse.of(oauth2LogoutUrl)); } - @PostMapping("/refresh") - @Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. JwtAuthenticationFilter가 자동으로 토큰을 갱신합니다.") - public ResponseEntity refreshToken(Authentication authentication) { - if (authentication == null || authentication.getPrincipal() == null) { - log.warn("토큰 재발급 실패: {}", LOG_INVALID_AUTH); - throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); + @PostMapping("/logout") + @Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다. 로컬 로그인과 소셜 로그인 모두 지원합니다. OAuth2 회원의 경우 제공자 로그아웃 URL을 반환합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "로그아웃 성공. OAuth2 회원의 경우 oauth2LogoutUrl 포함") + }) + public ResponseEntity logout(Authentication authentication, HttpServletResponse response) { + String loginId = null; + + if (authentication != null) { + // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) + if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details로 로그아웃: loginId={}", loginId); + } + // 2순위: PrincipalDetails (OAuth2 직접 로그인) + else if (authentication.getPrincipal() 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()); + } + // 3순위: memberId로 조회 (하위 호환성) + else if (authentication.getPrincipal() instanceof Long memberId) { + loginId = memberService.getLoginIdByMemberId(memberId); + log.info("memberId로 로그아웃: memberId={}, loginId={}", memberId, loginId); + } } - Long memberId = (Long) authentication.getPrincipal(); - MemberResponse response = memberService.getMemberById(memberId); - log.info("토큰 재발급 성공: memberId={}", memberId); - return ResponseEntity.ok(response); + // OAuth2 제공자에서 로그아웃 (카카오/네이버 서버 세션 삭제) + boolean oauth2LogoutSuccess = false; + if (loginId != null) { + oauth2LogoutSuccess = oauth2LogoutService.logoutFromOAuth2Provider(loginId); + if (oauth2LogoutSuccess) { + log.info("OAuth2 제공자 로그아웃 성공: loginId={}", loginId); + } + } + + // 로컬 로그아웃 처리 (Redis에서 토큰 삭제 + 쿠키 삭제) + memberService.logout(loginId != null ? loginId : "", response); + + // OAuth2 로그아웃 URL 조회 (클라이언트 리다이렉트용, 선택적) + String oauth2LogoutUrl = oauth2LogoutService.getOAuth2LogoutUrl(loginId); + + if (loginId != null) { + log.info("로그아웃 완료: loginId={}, oauth2Logout={}, oauth2LogoutUrl={}", + loginId, oauth2LogoutSuccess, oauth2LogoutUrl); + } else { + log.info("인증 정보 없이 로그아웃 완료 (쿠키만 삭제)"); + } + + return ResponseEntity.ok(LogoutResponse.of(oauth2LogoutUrl)); } @DeleteMapping("/withdraw") - @Operation(summary = "10. 회원탈퇴", description = "현재 로그인된 사용자의 계정을 삭제합니다.") - public ResponseEntity withdraw(Authentication authentication, HttpServletResponse response) { - if (authentication == null || authentication.getPrincipal() == null) { - throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("인증이 필요합니다."); + @Operation(summary = "10. 회원 탈퇴", description = "현재 로그인된 사용자의 회원 탈퇴를 진행합니다. OAuth2 회원의 경우 제공자 연동도 해제됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회원 탈퇴 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> withdraw(Authentication authentication, HttpServletResponse response) { + String loginId = null; + + if (authentication != null) { + // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) + if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details로 회원 탈퇴: loginId={}", loginId); + } + // 2순위: PrincipalDetails (OAuth2 직접 로그인) + else if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { + com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); + loginId = member.getLoginId(); + log.info("PrincipalDetails로 회원 탈퇴: loginId={}", loginId); + } + // 3순위: memberId로 조회 (하위 호환성) + else if (authentication.getPrincipal() instanceof Long memberId) { + loginId = memberService.getLoginIdByMemberId(memberId); + log.info("memberId로 회원 탈퇴: memberId={}, loginId={}", memberId, loginId); + } } - Long memberId = (Long) authentication.getPrincipal(); - String loginId = (String) authentication.getDetails(); - memberService.withdraw(memberId); + + if (loginId == null) { + log.warn("인증 정보 없이 회원 탈퇴 시도"); + return ResponseEntity.status(401).body(Map.of( + "success", false, + "message", "인증이 필요합니다." + )); + } + + // OAuth2 제공자 연동 해제 + boolean oauth2UnlinkSuccess = oauth2LogoutService.unlinkFromOAuth2Provider(loginId); + if (oauth2UnlinkSuccess) { + log.info("OAuth2 연동 해제 성공: loginId={}", loginId); + } + + // 로컬 로그아웃 처리 (Redis에서 토큰 삭제 + 쿠키 삭제) memberService.logout(loginId, response); - return ResponseEntity.ok().build(); + + // 회원 정보 삭제 + memberService.deleteMember(loginId); + + log.info("회원 탈퇴 완료: loginId={}, oauth2Unlink={}", loginId, oauth2UnlinkSuccess); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "회원 탈퇴가 완료되었습니다.", + "oauth2Unlinked", oauth2UnlinkSuccess + )); + } + + @PostMapping("/refresh") + @Operation(summary = "04. 토큰 재발급", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 재발급 성공"), + @ApiResponse(responseCode = "401", description = "유효하지 않은 리프레시 토큰") + }) + public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) { + // 쿠키에서 리프레시 토큰 추출 + String refreshToken = extractRefreshTokenFromCookies(request); + + if (refreshToken == null) { + log.warn("리프레시 토큰이 없습니다."); + throw new com.ai.lawyer.domain.member.exception.MemberAuthenticationException("리프레시 토큰이 필요합니다."); + } + + // 토큰 재발급 + MemberResponse memberResponse = memberService.refreshToken(refreshToken, response); + log.info("토큰 재발급 성공: memberId={}", memberResponse.getMemberId()); + return ResponseEntity.ok(memberResponse); } + private String extractRefreshTokenFromCookies(HttpServletRequest request) { + if (request.getCookies() != null) { + for (jakarta.servlet.http.Cookie cookie : request.getCookies()) { + if ("refreshToken".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } + + @GetMapping("/me") @Operation(summary = "03. 내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.") public ResponseEntity getMyInfo(Authentication authentication) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/dto/LogoutResponse.java b/backend/src/main/java/com/ai/lawyer/domain/member/dto/LogoutResponse.java new file mode 100644 index 00000000..7fe52861 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/dto/LogoutResponse.java @@ -0,0 +1,20 @@ +package com.ai.lawyer.domain.member.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LogoutResponse { + private boolean success; + private String message; + private String oauth2LogoutUrl; // OAuth2 제공자 로그아웃 URL (없으면 null) + + public static LogoutResponse of(String oauth2LogoutUrl) { + return LogoutResponse.builder() + .success(true) + .message("로그아웃 성공") + .oauth2LogoutUrl(oauth2LogoutUrl) + .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..87774866 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 @@ -152,13 +152,6 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp return MemberResponse.from(member); } - @Transactional - public void withdraw(Long memberId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND)); - - memberRepository.delete(member); - } public MemberResponse getMemberById(Long memberId) { // Member 또는 OAuth2Member 조회 @@ -175,6 +168,44 @@ 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(); + } + + @Transactional + public void deleteMember(String loginId) { + // Member 또는 OAuth2Member 삭제 + java.util.Optional regularMember = memberRepository.findByLoginId(loginId); + if (regularMember.isPresent()) { + memberRepository.delete(regularMember.get()); + log.info("일반 회원 삭제 완료: loginId={}", loginId); + return; + } + + if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + if (oauth2Member.isPresent()) { + oauth2MemberRepository.delete(oauth2Member.get()); + log.info("OAuth2 회원 삭제 완료: loginId={}", loginId); + return; + } + } + + log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId); + } + 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..a7a8978d 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 @@ -1,12 +1,12 @@ package com.ai.lawyer.global.jwt; -import com.ai.lawyer.domain.member.entity.MemberAdapter; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -25,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private TokenProvider tokenProvider; private CookieUtil cookieUtil; private MemberRepository memberRepository; + @Getter private OAuth2MemberRepository oauth2MemberRepository; public JwtAuthenticationFilter() { @@ -56,14 +57,8 @@ public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberReposit private static final String DEFAULT_ROLE = "USER"; // 로그 메시지 상수 - private static final String LOG_TOKEN_EXPIRED = "액세스 토큰 만료, 리프레시 토큰으로 갱신 시도"; - private static final String LOG_INVALID_TOKEN = "유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도"; - private static final String LOG_NO_REFRESH_TOKEN = "리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요"; - private static final String LOG_LOGIN_ID_EXTRACTION_FAILED = "loginId 추출 실패 - 쿠키 클리어"; - private static final String LOG_INVALID_REFRESH_TOKEN = "유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}"; - private static final String LOG_MEMBER_NOT_FOUND = "존재하지 않는 회원 - 쿠키 클리어: {}"; - private static final String LOG_TOKEN_REFRESH_SUCCESS = "토큰 자동 갱신 성공: {}"; - private static final String LOG_TOKEN_REFRESH_FAILED = "토큰 갱신 처리 실패: {}"; + private static final String LOG_TOKEN_EXPIRED = "액세스 토큰 만료 - 401 반환"; + private static final String LOG_INVALID_TOKEN = "유효하지 않은 액세스 토큰 - 401 반환"; private static final String LOG_JWT_AUTH_ERROR = "JWT 인증 처리 중 오류 발생: {}"; private static final String LOG_MEMBER_ID_EXTRACTION_FAILED = "토큰에서 memberId를 추출할 수 없습니다."; private static final String LOG_SET_AUTH_FAILED = "인증 정보 설정 실패: {}"; @@ -80,12 +75,20 @@ 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); } catch (Exception e) { log.error(LOG_JWT_AUTH_ERROR, e.getMessage(), e); - clearAuthenticationAndCookies(response); + SecurityContextHolder.clearContext(); } } @@ -94,24 +97,32 @@ 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/"); + } + /** * 인증 프로세스를 처리합니다. */ - private void processAuthentication(HttpServletRequest request, HttpServletResponse response) { + private void processAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException { String accessToken = cookieUtil.getAccessTokenFromCookies(request); if (accessToken != null) { - handleAccessToken(request, response, accessToken); - } else { - // 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인 - handleTokenRefresh(request, response, null); + handleAccessToken(response, accessToken); } + // 액세스 토큰이 없는 경우 인증 처리하지 않음 (공개 API 허용) } /** * 액세스 토큰을 검증하고 처리합니다. */ - private void handleAccessToken(HttpServletRequest request, HttpServletResponse response, String accessToken) { + private void handleAccessToken(HttpServletResponse response, String accessToken) throws IOException { TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); switch (validationResult) { @@ -120,18 +131,28 @@ private void handleAccessToken(HttpServletRequest request, HttpServletResponse r setAuthentication(accessToken); break; case EXPIRED: - // 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도 + // 만료된 액세스 토큰 - 401 반환 log.info(LOG_TOKEN_EXPIRED); - handleTokenRefresh(request, response, accessToken); + sendUnauthorizedError(response); break; case INVALID: - // 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인 + // 유효하지 않은 액세스 토큰 - 401 반환 log.warn(LOG_INVALID_TOKEN); - handleTokenRefresh(request, response, null); + sendUnauthorizedError(response); break; } } + /** + * 401 Unauthorized 응답을 반환합니다. + */ + private void sendUnauthorizedError(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"토큰이 만료되었거나 유효하지 않습니다. /api/auth/refresh를 호출하여 토큰을 재발급 받으세요.\"}"); + } + /** * JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다. * @param token JWT 액세스 토큰 @@ -180,102 +201,4 @@ private String buildAuthority(String role) { return ROLE_PREFIX + (role != null ? role : DEFAULT_ROLE); } - /** - * 리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다. - * RTR(Refresh Token Rotation) 패턴을 적용하여 새로운 토큰 쌍을 생성합니다. - */ - private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { - try { - String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); - if (refreshToken == null) { - log.info(LOG_NO_REFRESH_TOKEN); - clearAuthenticationAndCookies(response); - return; - } - - String loginId = extractLoginId(expiredAccessToken, refreshToken); - if (loginId == null) { - log.warn(LOG_LOGIN_ID_EXTRACTION_FAILED); - clearAuthenticationAndCookies(response); - return; - } - - if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { - log.info(LOG_INVALID_REFRESH_TOKEN, loginId); - clearAuthenticationAndCookies(response); - return; - } - - MemberAdapter member = findMemberByLoginId(loginId); - if (member == null) { - log.warn(LOG_MEMBER_NOT_FOUND, loginId); - clearAuthenticationAndCookies(response); - return; - } - - refreshTokensAndSetAuthentication(response, loginId, member); - log.info(LOG_TOKEN_REFRESH_SUCCESS, loginId); - - } catch (Exception e) { - log.error(LOG_TOKEN_REFRESH_FAILED, e.getMessage(), e); - clearAuthenticationAndCookies(response); - } - } - - /** - * loginId를 추출합니다 (만료된 토큰 또는 리프레시 토큰에서). - */ - private String extractLoginId(String expiredAccessToken, String refreshToken) { - String loginId = null; - if (expiredAccessToken != null) { - loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); - } - if (loginId == null) { - loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); - } - return loginId; - } - - /** - * loginId로 회원 정보를 조회합니다 (Member 또는 OAuth2Member). - */ - private MemberAdapter findMemberByLoginId(String loginId) { - MemberAdapter member = memberRepository.findByLoginId(loginId).orElse(null); - - if (member == null && oauth2MemberRepository != null) { - member = oauth2MemberRepository.findByLoginId(loginId).orElse(null); - } - - return member; - } - - /** - * RTR 패턴으로 토큰을 갱신하고 인증을 설정합니다. - */ - private void refreshTokensAndSetAuthentication(HttpServletResponse response, String loginId, MemberAdapter member) { - // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 - tokenProvider.deleteAllTokens(loginId); - - // 새로운 액세스 토큰과 리프레시 토큰 생성 - String newAccessToken = tokenProvider.generateAccessToken(member); - String newRefreshToken = tokenProvider.generateRefreshToken(member); - - // 새로운 토큰들을 쿠키에 설정 - cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); - - // 새로운 액세스 토큰으로 인증 설정 - setAuthentication(newAccessToken); - } - - /** - * 인증 정보와 쿠키를 모두 클리어합니다. - */ - private void clearAuthenticationAndCookies(HttpServletResponse response) { - // Spring Security 인증 정보 클리어 - SecurityContextHolder.clearContext(); - - // 쿠키 클리어 - cookieUtil.clearTokenCookies(response); - } - } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java index b82b7ed1..251dc484 100644 --- a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java @@ -19,6 +19,7 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final OAuth2MemberRepository oauth2MemberRepository; + private final org.springframework.data.redis.core.RedisTemplate redisTemplate; @Override @Transactional @@ -26,7 +27,10 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic OAuth2User oAuth2User = super.loadUser(userRequest); String registrationId = userRequest.getClientRegistration().getRegistrationId(); - log.info("OAuth2 로그인 시도: provider={}", registrationId); + String accessToken = userRequest.getAccessToken().getTokenValue(); + + log.info("OAuth2 로그인 시도: provider={}, accessToken={}", + registrationId, accessToken.substring(0, Math.min(10, accessToken.length())) + "..."); OAuth2UserInfo userInfo = getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); @@ -49,9 +53,31 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic oauth2MemberRepository.save(member); + // OAuth2 provider의 access token을 Redis에 저장 (연동 해제용) + saveOAuth2ProviderAccessToken(userInfo.getEmail(), accessToken); + + // Note: JWT 토큰은 OAuth2SuccessHandler에서 생성되어 Redis에 저장됩니다. + return new PrincipalDetails(member, oAuth2User.getAttributes()); } + /** + * OAuth2 provider의 access token을 Redis에 저장합니다. + * 이 토큰은 소셜 연동 해제(회원 탈퇴) 시 사용됩니다. + * @param loginId 회원 loginId (email) + * @param accessToken OAuth2 provider access token + */ + private void saveOAuth2ProviderAccessToken(String loginId, String accessToken) { + try { + String key = "oauth2_provider_token:" + loginId; + // 7일 TTL 설정 (refresh token과 동일한 기간) + redisTemplate.opsForValue().set(key, accessToken, java.time.Duration.ofDays(7)); + log.info("OAuth2 provider access token 저장 완료: loginId={}", loginId); + } catch (Exception e) { + log.error("OAuth2 provider access token 저장 실패: loginId={}, error={}", loginId, e.getMessage()); + } + } + private OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { if ("kakao".equalsIgnoreCase(registrationId)) { return new KakaoUserInfo(attributes); 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..b9e444dc 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,24 +17,44 @@ @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; + private final OAuth2TestPageUtil oauth2TestPageUtil; + + public OAuth2FailureHandler(OAuth2TestPageUtil oauth2TestPageUtil) { + this.oauth2TestPageUtil = oauth2TestPageUtil; + } + @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { log.error("OAuth2 로그인 실패: {}", exception.getMessage()); - // 에러 메시지를 URL-safe하게 인코딩 - 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로 설정하여 이미 인코딩된 값을 사용 - .toUriString(); - - log.info("OAuth2 로그인 실패, 백엔드 콜백으로 리다이렉트: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + // mode 파라미터 확인 (기본값: frontend) + String mode = request.getParameter("mode"); + + if ("backend".equals(mode)) { + // 백엔드 테스트 모드: HTML 에러 페이지 반환 (팝업 자동 닫기 포함) + log.info("OAuth2 로그인 실패 (백엔드 테스트 모드)"); + response.setContentType("text/html;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류"; + + String htmlContent = oauth2TestPageUtil.getFailurePageHtml(errorMessage); + response.getWriter().write(htmlContent); + } else { + // 프론트엔드 모드: 리다이렉트 + 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) + .toUriString(); + 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/OAuth2LogoutService.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2LogoutService.java new file mode 100644 index 00000000..d5d9b3de --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2LogoutService.java @@ -0,0 +1,347 @@ +package com.ai.lawyer.global.oauth; + +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuth2LogoutService { + + private final OAuth2MemberRepository oauth2MemberRepository; + private final RestTemplate restTemplate = new RestTemplate(); + private final org.springframework.data.redis.core.RedisTemplate redisTemplate; + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String kakaoClientId; + + @Setter + @Getter + @Value("${spring.security.oauth2.client.registration.kakao.client-secret}") + private String kakaoClientSecret; + + @Value("${spring.security.oauth2.client.registration.naver.client-id}") + private String naverClientId; + + @Value("${spring.security.oauth2.client.registration.naver.client-secret}") + private String naverClientSecret; + + @Value("${custom.frontend.url}") + private String frontendUrl; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + private static final String BACKEND_LOGOUT_REDIRECT = "http://localhost:8080/api/auth/oauth2/test-page"; + private static final int HEALTH_CHECK_TIMEOUT = 2000; // 2초 + + /** + * OAuth2 제공자 로그아웃을 백엔드에서 직접 처리합니다. + * @param loginId 회원 loginId + * @return 로그아웃 성공 여부 + */ + public boolean logoutFromOAuth2Provider(String loginId) { + if (loginId == null || loginId.isEmpty()) { + return false; + } + + Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + + if (oauth2Member.isEmpty()) { + log.info("일반 회원 로그아웃: loginId={}", loginId); + return false; + } + + OAuth2Member member = oauth2Member.get(); + OAuth2Member.Provider provider = member.getProvider(); + String providerId = member.getProviderId(); + + log.info("OAuth2 회원 로그아웃 시도: loginId={}, provider={}, providerId={}", loginId, provider, providerId); + + try { + return switch (provider) { + case KAKAO -> logoutFromKakao(loginId); + case NAVER -> unlinkFromNaver(loginId); + }; + } catch (Exception e) { + log.error("OAuth2 로그아웃 실패: loginId={}, provider={}, error={}", loginId, provider, e.getMessage()); + return false; + } + } + + /** + * OAuth2 제공자의 로그아웃 URL을 반환합니다. (클라이언트 리다이렉트용) + * @param loginId 회원 loginId + * @return OAuth2 제공자 로그아웃 URL (OAuth2 회원이 아니면 null) + */ + public String getOAuth2LogoutUrl(String loginId) { + if (loginId == null || loginId.isEmpty()) { + return null; + } + + Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + + if (oauth2Member.isEmpty()) { + log.info("일반 회원 로그아웃: loginId={}", loginId); + return null; + } + + OAuth2Member member = oauth2Member.get(); + OAuth2Member.Provider provider = member.getProvider(); + + log.info("OAuth2 회원 로그아웃 URL 생성: loginId={}, provider={}", loginId, provider); + + return switch (provider) { + case KAKAO -> buildKakaoLogoutUrl(); + case NAVER -> buildNaverLogoutUrl(); + }; + } + + /** + * 카카오 로그아웃 + * 참고: OAuth2 provider의 access token을 저장하지 않으므로 + * 클라이언트 측에서 처리하거나 세션만 종료합니다. + */ + private boolean logoutFromKakao(String loginId) { + log.info("카카오 로그아웃: loginId={} (로컬 세션만 종료)", loginId); + // OAuth2 provider access token을 저장하지 않으므로 + // 로컬 세션 종료만 수행합니다. + // 실제 카카오 로그아웃은 클라이언트에서 처리해야 합니다. + return true; + } + + /** + * 네이버 로그아웃 + * 참고: 네이버는 공식 로그아웃 API가 없으며, OAuth2 provider access token을 저장하지 않으므로 + * 로컬 세션 종료만 수행합니다. + */ + private boolean unlinkFromNaver(String loginId) { + log.info("네이버 로그아웃: loginId={} (로컬 세션만 종료)", loginId); + // OAuth2 provider access token을 저장하지 않으므로 + // 로컬 세션 종료만 수행합니다. + return true; + } + + /** + * 카카오 로그아웃 URL 생성 (클라이언트 리다이렉트용) + * 개발 환경에서 프론트엔드 헬스체크 후 폴백 + */ + private String buildKakaoLogoutUrl() { + String logoutRedirectUri; + + // 개발 환경에서 프론트엔드 헬스체크 + if (isDevelopmentEnvironment() && !isFrontendAvailable()) { + log.warn("프론트엔드 서버({})가 응답하지 않습니다. 백엔드 로그아웃 페이지로 폴백합니다.", frontendUrl); + logoutRedirectUri = BACKEND_LOGOUT_REDIRECT; + } else { + logoutRedirectUri = frontendUrl + "/login"; + } + + log.info("카카오 로그아웃 리다이렉트 URI: {}", logoutRedirectUri); + + return String.format( + "https://kauth.kakao.com/oauth/logout?client_id=%s&logout_redirect_uri=%s", + kakaoClientId, + logoutRedirectUri + ); + } + + /** + * 개발 환경인지 확인 + */ + private boolean isDevelopmentEnvironment() { + return "dev".equals(activeProfile) || "local".equals(activeProfile); + } + + /** + * 프론트엔드 서버가 동작 중인지 확인 + */ + private boolean isFrontendAvailable() { + try { + java.net.URI uri = java.net.URI.create(frontendUrl); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) uri.toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(HEALTH_CHECK_TIMEOUT); + connection.setReadTimeout(HEALTH_CHECK_TIMEOUT); + connection.connect(); + + int responseCode = connection.getResponseCode(); + connection.disconnect(); + + // 200-299 또는 404도 서버가 살아있는 것으로 간주 + boolean isAvailable = (responseCode >= 200 && responseCode < 300) || responseCode == 404; + log.debug("프론트엔드 헬스체크: {} - 응답코드 {}", frontendUrl, responseCode); + return isAvailable; + } catch (Exception e) { + log.debug("프론트엔드 헬스체크 실패: {}", e.getMessage()); + return false; + } + } + + /** + * 네이버 로그아웃 URL 생성 (클라이언트 리다이렉트용) + * 참고: 네이버는 공식 로그아웃 API가 없으므로 null 반환 + */ + private String buildNaverLogoutUrl() { + // 네이버는 공식 로그아웃 API를 제공하지 않습니다. + // 프론트엔드에서 쿠키/세션 삭제로 처리 + return null; + } + + /** + * OAuth2 제공자에서 회원 연동 해제 (회원 탈퇴) + * @param loginId 회원 loginId + * @return 연동 해제 성공 여부 + */ + public boolean unlinkFromOAuth2Provider(String loginId) { + if (loginId == null || loginId.isEmpty()) { + return false; + } + + Optional oauth2Member = oauth2MemberRepository.findByLoginId(loginId); + + if (oauth2Member.isEmpty()) { + log.info("일반 회원 연동 해제: loginId={}", loginId); + return false; + } + + OAuth2Member member = oauth2Member.get(); + OAuth2Member.Provider provider = member.getProvider(); + + log.info("OAuth2 회원 연동 해제 시도: loginId={}, provider={}", loginId, provider); + + try { + return switch (provider) { + case KAKAO -> unlinkFromKakao(loginId); + case NAVER -> unlinkFromNaverApp(loginId); + }; + } catch (Exception e) { + log.error("OAuth2 연동 해제 실패: loginId={}, provider={}, error={}", loginId, provider, e.getMessage()); + return false; + } + } + + /** + * 카카오 연동 해제 (회원 탈퇴) + * Redis에 저장된 OAuth2 provider access token을 사용하여 실제 카카오 연동 해제를 수행합니다. + */ + private boolean unlinkFromKakao(String loginId) { + log.info("카카오 연동 해제 시도: loginId={}", loginId); + + // Redis에서 OAuth2 provider access token 조회 + String accessToken = getOAuth2ProviderAccessToken(loginId); + + if (accessToken == null) { + log.warn("카카오 연동 해제 실패: OAuth2 provider access token이 없습니다. loginId={}", loginId); + return false; + } + + try { + // 카카오 연동 해제 API 호출 + String url = "https://kapi.kakao.com/v1/user/unlink"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + log.info("카카오 연동 해제 성공: loginId={}", loginId); + deleteOAuth2ProviderAccessToken(loginId); + return true; + } else { + log.warn("카카오 연동 해제 실패: loginId={}, status={}", loginId, response.getStatusCode()); + return false; + } + } catch (Exception e) { + log.error("카카오 연동 해제 API 호출 실패: loginId={}, error={}", loginId, e.getMessage()); + // API 호출 실패 시에도 로컬 처리는 계속 진행 + deleteOAuth2ProviderAccessToken(loginId); + return false; + } + } + + /** + * 네이버 연동 해제 (회원 탈퇴) + * Redis에 저장된 OAuth2 provider access token을 사용하여 실제 네이버 연동 해제를 수행합니다. + */ + private boolean unlinkFromNaverApp(String loginId) { + log.info("네이버 연동 해제 시도: loginId={}", loginId); + + // Redis에서 OAuth2 provider access token 조회 + String accessToken = getOAuth2ProviderAccessToken(loginId); + + if (accessToken == null) { + log.warn("네이버 연동 해제 실패: OAuth2 provider access token이 없습니다. loginId={}", loginId); + return false; + } + + try { + // 네이버 연동 해제 API 호출 + String url = String.format( + "https://nid.naver.com/oauth2.0/token?grant_type=delete&client_id=%s&client_secret=%s&access_token=%s&service_provider=NAVER", + naverClientId, + naverClientSecret, + accessToken + ); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + log.info("네이버 연동 해제 성공: loginId={}", loginId); + deleteOAuth2ProviderAccessToken(loginId); + return true; + } else { + log.warn("네이버 연동 해제 실패: loginId={}, status={}", loginId, response.getStatusCode()); + return false; + } + } catch (Exception e) { + log.error("네이버 연동 해제 API 호출 실패: loginId={}, error={}", loginId, e.getMessage()); + // API 호출 실패 시에도 로컬 처리는 계속 진행 + deleteOAuth2ProviderAccessToken(loginId); + return false; + } + } + + /** + * Redis에서 OAuth2 provider access token을 조회합니다. + * @param loginId 회원 loginId + * @return OAuth2 provider access token 또는 null + */ + private String getOAuth2ProviderAccessToken(String loginId) { + try { + String key = "oauth2_provider_token:" + loginId; + return (String) redisTemplate.opsForValue().get(key); + } catch (Exception e) { + log.error("OAuth2 provider access token 조회 실패: loginId={}, error={}", loginId, e.getMessage()); + return null; + } + } + + /** + * Redis에서 OAuth2 provider access token을 삭제합니다. + * @param loginId 회원 loginId + */ + private void deleteOAuth2ProviderAccessToken(String loginId) { + try { + String key = "oauth2_provider_token:" + loginId; + redisTemplate.delete(key); + log.info("OAuth2 provider access token 삭제 완료: loginId={}", loginId); + } catch (Exception e) { + log.error("OAuth2 provider access token 삭제 실패: loginId={}, error={}", loginId, e.getMessage()); + } + } + +} 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..155d53f8 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,9 +10,10 @@ 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; +import java.net.HttpURLConnection; +import java.net.URI; @Slf4j @Component @@ -22,8 +23,14 @@ 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}") - private String redirectUrl; + @Value("${custom.oauth2.redirect-url}") + private String frontendRedirectUrl; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + private static final String BACKEND_SUCCESS_PAGE = "/api/auth/oauth2/success-page"; + private static final int HEALTH_CHECK_TIMEOUT = 2000; // 2초 @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -41,12 +48,63 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 쿠키에 토큰 설정 cookieUtil.setTokenCookies(response, accessToken, refreshToken); - // 백엔드 콜백 엔드포인트로 리다이렉트 - String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl) - .build() - .toUriString(); + log.info("JWT 토큰 생성 완료 및 쿠키 설정 완료"); + + // mode 파라미터 확인 + String mode = request.getParameter("mode"); + + if ("backend".equals(mode)) { + // 백엔드 테스트 모드: JSON 응답 반환 + log.info("OAuth2 로그인 완료 (백엔드 테스트 모드)"); + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(String.format( + "{\"success\":true,\"message\":\"OAuth2 로그인 성공\",\"memberId\":%d,\"loginId\":\"%s\",\"accessToken\":\"%s\",\"refreshToken\":\"%s\"}", + member.getMemberId(), member.getLoginId(), accessToken, refreshToken + )); + } else { + // 개발 환경에서 프론트엔드 헬스체크 + String targetUrl = frontendRedirectUrl; + + if (isDevelopmentEnvironment() && !isFrontendAvailable()) { + log.warn("프론트엔드 서버({}})가 응답하지 않습니다. 백엔드 성공 페이지로 폴백합니다.", frontendRedirectUrl); + targetUrl = request.getContextPath() + BACKEND_SUCCESS_PAGE; + } + + log.info("OAuth2 로그인 완료, 리다이렉트: {}", targetUrl); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + } + + /** + * 개발 환경인지 확인 + */ + private boolean isDevelopmentEnvironment() { + return "dev".equals(activeProfile) || "local".equals(activeProfile); + } + + /** + * 프론트엔드 서버가 동작 중인지 확인 + */ + private boolean isFrontendAvailable() { + try { + URI uri = URI.create(frontendRedirectUrl); + HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(HEALTH_CHECK_TIMEOUT); + connection.setReadTimeout(HEALTH_CHECK_TIMEOUT); + connection.connect(); + + int responseCode = connection.getResponseCode(); + connection.disconnect(); - log.info("OAuth2 로그인 완료, 백엔드 콜백으로 리다이렉트: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + // 200-299 또는 404도 서버가 살아있는 것으로 간주 + boolean isAvailable = (responseCode >= 200 && responseCode < 300) || responseCode == 404; + log.debug("프론트엔드 헬스체크: {} - 응답코드 {}", frontendRedirectUrl, responseCode); + return isAvailable; + } catch (Exception e) { + log.debug("프론트엔드 헬스체크 실패: {}", e.getMessage()); + return false; + } } } diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2TestPageUtil.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2TestPageUtil.java new file mode 100644 index 00000000..1edf65a0 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2TestPageUtil.java @@ -0,0 +1,113 @@ +package com.ai.lawyer.global.oauth; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; + +/** + * OAuth2 테스트 페이지 HTML 파일을 읽어서 반환하는 유틸리티 클래스 + * 개발/테스트 전용 클래스입니다. + * 프로덕션 배포 시 이 클래스와 templates/oauth2-test 디렉토리를 삭제하세요. + */ +@Slf4j +@Component +public class OAuth2TestPageUtil { + + private static final String TEMPLATE_DIR = "templates/oauth2-test/"; + + /** + * OAuth2 테스트 메인 페이지 HTML 반환 + */ + public String getTestPageHtml() { + return readHtmlFile("test-page.html"); + } + + /** + * OAuth2 로그인 성공 페이지 HTML 반환 + * @param title 제목 + * @param message 메시지 + * @param details 상세 정보 (null 가능) + * @return HTML 문자열 + */ + public String getSuccessPageHtml(String title, String message, String details) { + String html = readHtmlFile("success-page.html"); + + // 플레이스홀더 치환 + html = html.replace("{{TITLE}}", escapeHtml(title)); + html = html.replace("{{MESSAGE}}", escapeHtml(message)); + html = html.replace("{{CLASS}}", message.contains("성공") ? "success" : "error"); + html = html.replace("{{DETAILS}}", + details != null ? "
" + escapeHtml(details) + "
" : ""); + + return html; + } + + /** + * OAuth2 로그인 실패 페이지 HTML 반환 + * @param errorMessage 에러 메시지 + * @return HTML 문자열 + */ + public String getFailurePageHtml(String errorMessage) { + String html = readHtmlFile("failure-page.html"); + html = html.replace("{{ERROR_MESSAGE}}", escapeHtml(errorMessage)); + return html; + } + + /** + * HTML 파일 읽기 + * @param fileName 파일명 + * @return HTML 문자열 + */ + private String readHtmlFile(String fileName) { + try { + ClassPathResource resource = new ClassPathResource(TEMPLATE_DIR + fileName); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + log.error("HTML 파일 읽기 실패: {}", fileName, e); + return getFallbackErrorHtml(fileName); + } + } + + /** + * 파일 읽기 실패 시 폴백 HTML + */ + private String getFallbackErrorHtml(String fileName) { + return String.format(""" + + + + + 오류 + + +

파일을 찾을 수 없습니다

+

%s 파일을 읽을 수 없습니다.

+ + + """, fileName); + } + + /** + * HTML 이스케이프 처리 (XSS 방지) + */ + private String escapeHtml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") + .replace("/", "/"); + } +} 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..af1e063f 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) + + // 세션 정책: JWT 기반 인증이므로 세션 사용 안 함 (STATELESS) .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - .headers(headers -> headers - .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) - ) + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 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/main/resources/templates/oauth2-test/failure-page.html b/backend/src/main/resources/templates/oauth2-test/failure-page.html new file mode 100644 index 00000000..4972b857 --- /dev/null +++ b/backend/src/main/resources/templates/oauth2-test/failure-page.html @@ -0,0 +1,109 @@ + + + + + OAuth2 로그인 실패 + + + +
+
+

OAuth2 로그인 실패

+
{{ERROR_MESSAGE}}
+
+ + +
+
+ + + diff --git a/backend/src/main/resources/templates/oauth2-test/success-page.html b/backend/src/main/resources/templates/oauth2-test/success-page.html new file mode 100644 index 00000000..6446f820 --- /dev/null +++ b/backend/src/main/resources/templates/oauth2-test/success-page.html @@ -0,0 +1,161 @@ + + + + + {{TITLE}} + + + +
+

{{TITLE}}

+

{{MESSAGE}}

+ {{DETAILS}} +
+ + API 문서 + 테스트 페이지 + +
+
+ + + diff --git a/backend/src/main/resources/templates/oauth2-test/test-page.html b/backend/src/main/resources/templates/oauth2-test/test-page.html new file mode 100644 index 00000000..20440cc9 --- /dev/null +++ b/backend/src/main/resources/templates/oauth2-test/test-page.html @@ -0,0 +1,440 @@ + + + + + + OAuth2 로그인 테스트 + + + +
+

🔐 OAuth2 로그인 테스트

+ +
+ + + + + + 📚 API 문서 + +
+ +
+ ⚠️ 네이버 세션 이슈 + 네이버는 공식 로그아웃 API를 제공하지 않아, 브라우저에 세션이 남아있을 수 있습니다. + 다른 계정으로 테스트하려면 아래 버튼을 클릭하여 네이버에서 직접 로그아웃하세요. +
+ + + + +
+ + + + + + + diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 5b2561cd..a9e10fa5 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -24,6 +24,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; @@ -48,6 +49,9 @@ class MemberControllerTest { @Mock private HttpServletResponse response; + @Mock + private com.ai.lawyer.global.oauth.OAuth2LogoutService oauth2LogoutService; + @InjectMocks private MemberController memberController; @@ -217,13 +221,17 @@ void login_Fail_PasswordMismatch() throws Exception { @DisplayName("로그아웃 성공 - Authentication에서 loginId 추출하여 Redis 삭제") void logout_Success() { // given + given(oauth2LogoutService.logoutFromOAuth2Provider(eq("test@example.com"))).willReturn(false); + given(oauth2LogoutService.getOAuth2LogoutUrl(eq("test@example.com"))).willReturn(null); doNothing().when(memberService).logout(eq("test@example.com"), eq(response)); // when - ResponseEntity result = memberController.logout(authentication, response); + ResponseEntity result = memberController.logout(authentication, response); // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(oauth2LogoutService).logoutFromOAuth2Provider(eq("test@example.com")); + verify(oauth2LogoutService).getOAuth2LogoutUrl(eq("test@example.com")); verify(memberService).logout(eq("test@example.com"), eq(response)); } @@ -231,89 +239,86 @@ void logout_Success() { @DisplayName("로그아웃 성공 - 인증되지 않은 상태에서도 쿠키 클리어") void logout_Success_Unauthenticated() { // given + given(oauth2LogoutService.getOAuth2LogoutUrl(null)).willReturn(null); doNothing().when(memberService).logout(eq(""), eq(response)); // when - ResponseEntity result = memberController.logout(null, response); + ResponseEntity result = memberController.logout(null, response); // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(oauth2LogoutService).getOAuth2LogoutUrl(null); verify(memberService).logout(eq(""), eq(response)); } @Test - @DisplayName("토큰 재발급 성공 - Authentication 기반") - void refreshToken_Success() { + @DisplayName("토큰 재발급 성공 - 쿠키에서 리프레시 토큰 추출") + void refreshToken_Success() throws Exception { // given - Long memberId = 1L; - Authentication testAuth = new UsernamePasswordAuthenticationToken( - memberId, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - given(memberService.getMemberById(memberId)).willReturn(memberResponse); + String refreshTokenValue = "validRefreshToken"; + jakarta.servlet.http.Cookie refreshCookie = new jakarta.servlet.http.Cookie("refreshToken", refreshTokenValue); + + given(memberService.refreshToken(eq(refreshTokenValue), any(HttpServletResponse.class))).willReturn(memberResponse); // when - ResponseEntity result = memberController.refreshToken(testAuth); + mockMvc.perform(post("/api/auth/refresh") + .cookie(refreshCookie) + .with(csrf())) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.memberId").value(1L)) + .andExpect(jsonPath("$.loginId").value("test@example.com")); // then - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(result.getBody()).isEqualTo(memberResponse); - verify(memberService).getMemberById(memberId); + verify(memberService).refreshToken(eq(refreshTokenValue), any(HttpServletResponse.class)); } @Test - @DisplayName("토큰 재발급 실패 - 인증 정보 없음") - void refreshToken_Fail_NoAuthentication() { - // given - authentication이 null인 경우 + @DisplayName("토큰 재발급 실패 - 리프레시 토큰 없음") + void refreshToken_Fail_NoRefreshToken() throws Exception { + // given - 쿠키 없이 요청 // when & then - assertThatThrownBy(() -> memberController.refreshToken(null)) - .isInstanceOf(MemberAuthenticationException.class) - .hasMessage("인증이 필요합니다."); - } - - @Test - @DisplayName("토큰 재발급 실패 - Principal 없음") - void refreshToken_Fail_NoPrincipal() { - // given - Authentication testAuth = new UsernamePasswordAuthenticationToken( - null, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); + mockMvc.perform(post("/api/auth/refresh") + .with(csrf())) + .andDo(print()) + .andExpect(status().isUnauthorized()); - // when & then - assertThatThrownBy(() -> memberController.refreshToken(testAuth)) - .isInstanceOf(MemberAuthenticationException.class) - .hasMessage("인증이 필요합니다."); + verify(memberService, never()).refreshToken(anyString(), any()); } @Test @DisplayName("회원탈퇴 성공") void withdraw_Success() { - // given - 현재 Controller는 직접 memberId를 사용 - doNothing().when(memberService).withdraw(1L); + // given + given(oauth2LogoutService.unlinkFromOAuth2Provider(eq("test@example.com"))).willReturn(false); doNothing().when(memberService).logout(eq("test@example.com"), eq(response)); + doNothing().when(memberService).deleteMember(eq("test@example.com")); // when - ResponseEntity result = memberController.withdraw(authentication, response); + ResponseEntity> result = memberController.withdraw(authentication, response); // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(memberService).withdraw(1L); + verify(oauth2LogoutService).unlinkFromOAuth2Provider(eq("test@example.com")); verify(memberService).logout(eq("test@example.com"), eq(response)); + verify(memberService).deleteMember(eq("test@example.com")); } @Test @DisplayName("회원탈퇴 실패 - 인증되지 않은 사용자") void withdraw_Fail_Unauthenticated() { - // when & then - assertThatThrownBy(() -> memberController.withdraw(null, response)) - .isInstanceOf(MemberAuthenticationException.class) - .hasMessage("인증이 필요합니다."); + // when + ResponseEntity> result = memberController.withdraw(null, response); + + // then + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().get("success")).isEqualTo(false); + assertThat(result.getBody().get("message")).isEqualTo("인증이 필요합니다."); - verify(memberService, never()).withdraw(anyLong()); + verify(oauth2LogoutService, never()).unlinkFromOAuth2Provider(anyString()); + verify(memberService, never()).deleteMember(anyString()); verify(memberService, never()).logout(anyString(), any()); } @@ -321,16 +326,19 @@ void withdraw_Fail_Unauthenticated() { @DisplayName("회원탈퇴 실패 - 존재하지 않는 회원") void withdraw_Fail_MemberNotFound() { // given + given(oauth2LogoutService.unlinkFromOAuth2Provider(eq("test@example.com"))).willReturn(false); + doNothing().when(memberService).logout(eq("test@example.com"), eq(response)); doThrow(new IllegalArgumentException("존재하지 않는 회원입니다.")) - .when(memberService).withdraw(1L); + .when(memberService).deleteMember(eq("test@example.com")); // when & then assertThatThrownBy(() -> memberController.withdraw(authentication, response)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("존재하지 않는 회원입니다."); - verify(memberService).withdraw(1L); - verify(memberService, never()).logout(anyString(), any()); + verify(oauth2LogoutService).unlinkFromOAuth2Provider(eq("test@example.com")); + verify(memberService).logout(eq("test@example.com"), eq(response)); + verify(memberService).deleteMember(eq("test@example.com")); } @Test @@ -699,4 +707,5 @@ void verifyEmail_Success_LoggedInUser() throws Exception { verify(memberService).verifyAuthCode("test@example.com", "123456"); } + } \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index a02d487b..1c06e932 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -293,14 +293,14 @@ void refreshToken_Fail_InvalidToken() { @DisplayName("회원탈퇴 성공") void withdraw_Success() { // given - Long memberId = 1L; - given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + String loginId = "test@example.com"; + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); // when - memberService.withdraw(memberId); + memberService.deleteMember(loginId); // then - verify(memberRepository).findById(memberId); + verify(memberRepository).findByLoginId(loginId); verify(memberRepository).delete(member); } @@ -308,15 +308,14 @@ void withdraw_Success() { @DisplayName("회원탈퇴 실패 - 존재하지 않는 회원") void withdraw_Fail_MemberNotFound() { // given - Long memberId = 999L; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + String loginId = "nonexistent@example.com"; + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.empty()); - // when and then - assertThatThrownBy(() -> memberService.withdraw(memberId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("존재하지 않는 회원입니다."); + // when + memberService.deleteMember(loginId); // 존재하지 않아도 예외 발생하지 않음 (로그만 출력) - verify(memberRepository).findById(memberId); + // then + verify(memberRepository).findByLoginId(loginId); verify(memberRepository, never()).delete(any()); } diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index 3d6510b4..9e92d267 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -47,6 +47,14 @@ class PollControllerTest { private com.ai.lawyer.domain.member.repositories.MemberRepository memberRepository; @MockitoBean private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; + @MockitoBean + private org.springframework.data.redis.core.RedisTemplate redisTemplate; + @MockitoBean + private com.ai.lawyer.global.oauth.CustomOAuth2UserService customOAuth2UserService; + @MockitoBean + private com.ai.lawyer.global.oauth.OAuth2SuccessHandler oauth2SuccessHandler; + @MockitoBean + private com.ai.lawyer.global.oauth.OAuth2FailureHandler oauth2FailureHandler; @BeforeEach void setUp() { diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java index 3017c391..cdc5d978 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java @@ -51,6 +51,14 @@ class PostControllerTest { private com.ai.lawyer.global.jwt.CookieUtil cookieUtil; @MockitoBean private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; + @MockitoBean + private org.springframework.data.redis.core.RedisTemplate redisTemplate; + @MockitoBean + private com.ai.lawyer.global.oauth.CustomOAuth2UserService customOAuth2UserService; + @MockitoBean + private com.ai.lawyer.global.oauth.OAuth2SuccessHandler oauth2SuccessHandler; + @MockitoBean + private com.ai.lawyer.global.oauth.OAuth2FailureHandler oauth2FailureHandler; @Autowired private ObjectMapper objectMapper; 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/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java index 91b9f5b3..8ff279a9 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java @@ -1,7 +1,5 @@ package com.ai.lawyer.global.jwt; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.entity.OAuth2Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import jakarta.servlet.FilterChain; @@ -17,8 +15,6 @@ import org.mockito.quality.Strictness; import org.springframework.security.core.context.SecurityContextHolder; -import java.util.Optional; - import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; @@ -51,10 +47,8 @@ class JwtAuthenticationFilterTest { private JwtAuthenticationFilter jwtAuthenticationFilter; - private Member testMember; private String validAccessToken; private String expiredAccessToken; - private String refreshToken; @BeforeEach void setUp() { @@ -67,29 +61,21 @@ void setUp() { jwtAuthenticationFilter.setMemberRepository(memberRepository); jwtAuthenticationFilter.setOauth2MemberRepository(oauth2MemberRepository); - testMember = Member.builder() - .loginId("test@example.com") - .password("encodedPassword") - .name("Test User") - .age(25) - .gender(Member.Gender.MALE) - .role(Member.Role.USER) - .build(); - validAccessToken = "validAccessToken"; expiredAccessToken = "expiredAccessToken"; - refreshToken = "refreshToken"; } @Test @DisplayName("유효한 쿠키 토큰으로 인증 성공") void doFilterInternal_ValidCookieToken_Success() throws Exception { // given + given(request.getRequestURI()).willReturn("/api/polls/1"); given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(validAccessToken); given(tokenProvider.validateTokenWithResult(validAccessToken)) .willReturn(TokenProvider.TokenValidationResult.VALID); given(tokenProvider.getMemberIdFromToken(validAccessToken)).willReturn(1L); given(tokenProvider.getRoleFromToken(validAccessToken)).willReturn("USER"); + given(response.getWriter()).willReturn(new java.io.PrintWriter(new java.io.StringWriter())); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -101,248 +87,67 @@ void doFilterInternal_ValidCookieToken_Success() throws Exception { } @Test - @DisplayName("만료된 쿠키 토큰으로 자동 리프레시 성공") - void doFilterInternal_ExpiredCookieToken_AutoRefreshSuccess() throws Exception { + @DisplayName("만료된 쿠키 토큰으로 401 에러 반환") + void doFilterInternal_ExpiredCookieToken_Returns401() throws Exception { // given - String newAccessToken = "newAccessToken"; - String newRefreshToken = "newRefreshToken"; - + given(request.getRequestURI()).willReturn("/api/polls/1"); given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); given(tokenProvider.validateTokenWithResult(expiredAccessToken)) .willReturn(TokenProvider.TokenValidationResult.EXPIRED); - - // 자동 리프레시 관련 - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); - given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); - given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); - given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); - given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); - given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); - - // 새 토큰으로 인증 설정 - given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); - given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); + given(response.getWriter()).willReturn(new java.io.PrintWriter(new java.io.StringWriter())); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(tokenProvider).deleteAllTokens("test@example.com"); - verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("리프레시 토큰이 없으면 쿠키 클리어") - void doFilterInternal_NoRefreshToken_ClearCookies() throws Exception { - // given - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); - given(tokenProvider.validateTokenWithResult(expiredAccessToken)) - .willReturn(TokenProvider.TokenValidationResult.EXPIRED); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - verify(cookieUtil).clearTokenCookies(response); - verify(tokenProvider, never()).validateRefreshToken(anyString(), anyString()); + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(response).setContentType("application/json"); + verify(response).setCharacterEncoding("UTF-8"); + verify(tokenProvider, never()).deleteAllTokens(anyString()); verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); } @Test - @DisplayName("액세스 토큰이 없으면 리프레시 토큰 확인") - void doFilterInternal_NoAccessToken_CheckRefreshToken() throws Exception { - // given - String newAccessToken = "newAccessToken"; - String newRefreshToken = "newRefreshToken"; - - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); - given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); - given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); - given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); - given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); - given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); - - // 새 토큰으로 인증 설정 - given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); - given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - verify(tokenProvider).deleteAllTokens("test@example.com"); - verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("유효하지 않은 쿠키 토큰으로 리프레시 시도") - void doFilterInternal_InvalidCookieToken_TryRefresh() throws Exception { + @DisplayName("유효하지 않은 쿠키 토큰으로 401 에러 반환") + void doFilterInternal_InvalidCookieToken_Returns401() throws Exception { // given String invalidToken = "invalidToken"; - String newAccessToken = "newAccessToken"; - String newRefreshToken = "newRefreshToken"; - + given(request.getRequestURI()).willReturn("/api/polls/1"); given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(invalidToken); given(tokenProvider.validateTokenWithResult(invalidToken)) .willReturn(TokenProvider.TokenValidationResult.INVALID); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); - given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); - given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); - given(oauth2MemberRepository.findByLoginId("test@example.com")).willReturn(Optional.empty()); - given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); - given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); - - // 새 토큰으로 인증 설정 - given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); - given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - verify(tokenProvider).deleteAllTokens("test@example.com"); - verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("모든 토큰이 없으면 쿠키 클리어") - void doFilterInternal_NoTokens_ClearCookies() throws Exception { - // given - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); + given(response.getWriter()).willReturn(new java.io.PrintWriter(new java.io.StringWriter())); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(cookieUtil).clearTokenCookies(response); + verify(response).setStatus(HttpServletResponse.SC_UNAUTHORIZED); + verify(tokenProvider, never()).deleteAllTokens(anyString()); + verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); } @Test - @DisplayName("OAuth2 회원 - 만료된 토큰으로 자동 리프레시 성공 (카카오)") - void doFilterInternal_OAuth2Member_KakaoRefresh() throws Exception { + @DisplayName("액세스 토큰이 없으면 인증 없이 통과") + void doFilterInternal_NoAccessToken_PassWithoutAuth() throws Exception { // given - OAuth2Member kakaoMember = OAuth2Member.builder() - .loginId("kakao@test.com") - .email("kakao@test.com") - .name("카카오사용자") - .age(30) - .gender(Member.Gender.MALE) - .provider(OAuth2Member.Provider.KAKAO) - .providerId("kakao123") - .role(Member.Role.USER) - .build(); - org.springframework.test.util.ReflectionTestUtils.setField(kakaoMember, "memberId", 10L); - - String newAccessToken = "newOAuth2AccessToken"; - String newRefreshToken = "newOAuth2RefreshToken"; - - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); - given(tokenProvider.validateTokenWithResult(expiredAccessToken)) - .willReturn(TokenProvider.TokenValidationResult.EXPIRED); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("kakao@test.com"); - given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(true); - given(memberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.empty()); - given(oauth2MemberRepository.findByLoginId("kakao@test.com")).willReturn(Optional.of(kakaoMember)); - given(tokenProvider.generateAccessToken(kakaoMember)).willReturn(newAccessToken); - given(tokenProvider.generateRefreshToken(kakaoMember)).willReturn(newRefreshToken); - given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(10L); - given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - verify(memberRepository).findByLoginId("kakao@test.com"); - verify(oauth2MemberRepository).findByLoginId("kakao@test.com"); - verify(tokenProvider).deleteAllTokens("kakao@test.com"); - verify(tokenProvider).generateAccessToken(kakaoMember); - verify(tokenProvider).generateRefreshToken(kakaoMember); - verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(10L); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("OAuth2 회원 - 액세스 토큰 없이 리프레시 토큰으로만 갱신 성공 (네이버)") - void doFilterInternal_OAuth2Member_NaverRefreshOnly() throws Exception { - // given - OAuth2Member naverMember = OAuth2Member.builder() - .loginId("naver@test.com") - .email("naver@test.com") - .name("네이버사용자") - .age(25) - .gender(Member.Gender.FEMALE) - .provider(OAuth2Member.Provider.NAVER) - .providerId("naver456") - .role(Member.Role.USER) - .build(); - org.springframework.test.util.ReflectionTestUtils.setField(naverMember, "memberId", 20L); - - String newAccessToken = "newNaverAccessToken"; - String newRefreshToken = "newNaverRefreshToken"; - + given(request.getRequestURI()).willReturn("/api/polls/1"); given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("naver@test.com"); - given(tokenProvider.validateRefreshToken("naver@test.com", refreshToken)).willReturn(true); - given(memberRepository.findByLoginId("naver@test.com")).willReturn(Optional.empty()); - given(oauth2MemberRepository.findByLoginId("naver@test.com")).willReturn(Optional.of(naverMember)); - given(tokenProvider.generateAccessToken(naverMember)).willReturn(newAccessToken); - given(tokenProvider.generateRefreshToken(naverMember)).willReturn(newRefreshToken); - given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(20L); - given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); - - // when - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - // then - verify(memberRepository).findByLoginId("naver@test.com"); - verify(oauth2MemberRepository).findByLoginId("naver@test.com"); - verify(tokenProvider).deleteAllTokens("naver@test.com"); - verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); - assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()).isEqualTo(20L); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("OAuth2 회원 - 유효하지 않은 리프레시 토큰으로 쿠키 클리어") - void doFilterInternal_OAuth2Member_InvalidRefreshToken() throws Exception { - // given - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); - given(tokenProvider.validateTokenWithResult(expiredAccessToken)) - .willReturn(TokenProvider.TokenValidationResult.EXPIRED); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); - given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("kakao@test.com"); - given(tokenProvider.validateRefreshToken("kakao@test.com", refreshToken)).willReturn(false); + given(response.getWriter()).willReturn(new java.io.PrintWriter(new java.io.StringWriter())); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(tokenProvider).validateRefreshToken("kakao@test.com", refreshToken); - verify(cookieUtil).clearTokenCookies(response); - verify(memberRepository, never()).findByLoginId(anyString()); - verify(oauth2MemberRepository, never()).findByLoginId(anyString()); + verify(tokenProvider, never()).validateTokenWithResult(anyString()); + verify(tokenProvider, never()).deleteAllTokens(anyString()); + verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); } -} \ No newline at end of file + +} 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..aa68015f 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,10 @@ class OAuth2SuccessHandlerTest { @BeforeEach void setUp() { - // Redirect URL 설정 - ReflectionTestUtils.setField(oauth2SuccessHandler, "redirectUrl", - "http://localhost:8080/api/auth/oauth2/callback/success"); + // Redirect URL 설정 (환경변수에서 주입되는 값) + ReflectionTestUtils.setField(oauth2SuccessHandler, "frontendRedirectUrl", + "http://localhost:3000/oauth/success"); + ReflectionTestUtils.setField(oauth2SuccessHandler, "activeProfile", "dev"); // 카카오 회원 생성 kakaoMember = OAuth2Member.builder() @@ -169,7 +170,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);