From e6ff74e61cb37d7e4e9145cc298f7c441d2b4fb6 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Fri, 10 Oct 2025 10:17:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix[OAuth]:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=B0=8F?= =?UTF-8?q?=20jwt=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=8F=20html=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 52 ++++-- .../domain/member/service/MemberService.java | 4 + .../com/ai/lawyer/global/jwt/CookieUtil.java | 4 +- .../global/oauth/OAuth2FailureHandler.java | 77 ++++++-- .../global/oauth/OAuth2SuccessHandler.java | 26 ++- .../templates/oauth2-test/success-page.html | 54 +++++- .../templates/oauth2-test/test-page.html | 167 ------------------ 7 files changed, 173 insertions(+), 211 deletions(-) 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 9ede09f4..8c90d7ab 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 @@ -107,28 +107,48 @@ public ResponseEntity oauth2TestPage() { @GetMapping("/oauth2/success-page") @Operation(summary = "15. OAuth2 로그인 성공 페이지 (백엔드 테스트용)", description = "프론트엔드 없이 백엔드에서 OAuth2 로그인 결과를 확인할 수 있는 페이지입니다.") - public ResponseEntity oauth2SuccessPage(Authentication authentication) { - if (authentication == null || authentication.getPrincipal() == null) { + public ResponseEntity oauth2SuccessPage(Authentication authentication, HttpServletRequest request) { + String loginId = null; + Long memberId = null; + + // 1. Authentication 객체에서 인증 정보 추출 시도 (OAuth2 직접 로그인) + if (authentication != null && authentication.getPrincipal() != null) { + Object principal = authentication.getPrincipal(); + + 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(); + } + } + + // 2. 쿠키에서 JWT 토큰 추출 시도 (리다이렉트 후) + if (loginId == null || memberId == null) { + String accessToken = extractAccessTokenFromRequest(request); + if (accessToken != null) { + try { + loginId = memberService.extractLoginIdFromToken(accessToken); + memberId = memberService.extractMemberIdFromToken(accessToken); + log.info("쿠키에서 인증 정보 추출 성공: loginId={}, memberId={}", loginId, memberId); + } catch (Exception e) { + log.warn("쿠키에서 인증 정보 추출 실패: {}", e.getMessage()); + } + } + } + + // 3. 인증 정보 확인 + if (loginId == null || memberId == null) { + log.warn("OAuth2 성공 페이지 접근 실패: 인증 정보 없음"); 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 로그인 성공", "로그인에 성공했습니다!", 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 87774866..0806e9b5 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 @@ -263,6 +263,10 @@ public String extractLoginIdFromToken(String token) { return tokenProvider.getLoginIdFromToken(token); } + public Long extractMemberIdFromToken(String token) { + return tokenProvider.getMemberIdFromToken(token); + } + @Transactional public MemberResponse oauth2LoginTest(OAuth2LoginTestRequest request, HttpServletResponse response) { if (oauth2MemberRepository == null) { 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 5e98fbc3..a498ae6f 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 @@ -23,9 +23,9 @@ public class CookieUtil { // 쿠키 보안 설정 상수 private static final boolean HTTP_ONLY = true; - private static final boolean SECURE_IN_PRODUCTION = true; // 운영환경에서는 true로 변경 (HTTPS) + private static final boolean SECURE_IN_PRODUCTION = false; // 개발환경에서는 false (HTTP), 운영환경에서는 true로 변경 (HTTPS) private static final String COOKIE_PATH = "/"; - private static final String SAME_SITE = "None"; // None, Lax, Strict 중 선택 + private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용 private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { 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 b9e444dc..f976c0f4 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 @@ -10,6 +10,8 @@ import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -20,8 +22,13 @@ public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler @Value("${custom.oauth2.failure-url}") private String failureUrl; + @Value("${spring.profiles.active:dev}") + private String activeProfile; + private final OAuth2TestPageUtil oauth2TestPageUtil; + private static final int HEALTH_CHECK_TIMEOUT = 2000; // 2초 + public OAuth2FailureHandler(OAuth2TestPageUtil oauth2TestPageUtil) { this.oauth2TestPageUtil = oauth2TestPageUtil; } @@ -31,30 +38,76 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo AuthenticationException exception) throws IOException { log.error("OAuth2 로그인 실패: {}", exception.getMessage()); - // mode 파라미터 확인 (기본값: frontend) + String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류"; + + // mode 파라미터 확인 String mode = request.getParameter("mode"); if ("backend".equals(mode)) { - // 백엔드 테스트 모드: HTML 에러 페이지 반환 (팝업 자동 닫기 포함) + // 백엔드 테스트 모드: 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); + // 프론트엔드 모드: 프론트엔드로 리다이렉트 또는 백엔드 실패 페이지 표시 + if (isDevelopmentEnvironment() && !isFrontendAvailable()) { + // 프론트엔드 서버가 없으면 백엔드 실패 페이지 HTML 직접 반환 + log.warn("프론트엔드 서버({})가 응답하지 않습니다. 백엔드 실패 페이지를 반환합니다.", failureUrl); + response.setContentType("text/html;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + String htmlContent = oauth2TestPageUtil.getFailurePageHtml(errorMessage); + response.getWriter().write(htmlContent); + } else { + // 프론트엔드 서버로 리다이렉트 + 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); + } + } + } + + /** + * 개발 환경인지 확인 + */ + private boolean isDevelopmentEnvironment() { + return "dev".equals(activeProfile) || "local".equals(activeProfile); + } + + /** + * 프론트엔드 서버가 동작 중인지 확인 + */ + private boolean isFrontendAvailable() { + try { + URI uri = URI.create(failureUrl); + // 쿼리 파라미터를 제거하고 베이스 URL만 추출 + String baseUrl = uri.getScheme() + "://" + uri.getHost() + + (uri.getPort() != -1 ? ":" + uri.getPort() : ""); + + HttpURLConnection connection = (HttpURLConnection) URI.create(baseUrl).toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(HEALTH_CHECK_TIMEOUT); + connection.setReadTimeout(HEALTH_CHECK_TIMEOUT); + connection.connect(); - String targetUrl = UriComponentsBuilder.fromUriString(failureUrl) - .queryParam("error", encodedError) - .build(true) - .toUriString(); - log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl); + int responseCode = connection.getResponseCode(); + connection.disconnect(); - getRedirectStrategy().sendRedirect(request, response, targetUrl); + // 200-299 또는 404도 서버가 살아있는 것으로 간주 + boolean isAvailable = (responseCode >= 200 && responseCode < 300) || responseCode == 404; + log.debug("프론트엔드 헬스체크: {} - 응답코드 {}", baseUrl, responseCode); + return isAvailable; + } catch (Exception e) { + log.debug("프론트엔드 헬스체크 실패: {}", e.getMessage()); + return false; } } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java b/backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java index 155d53f8..89dc51cb 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 @@ -22,6 +22,7 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler private final TokenProvider tokenProvider; private final CookieUtil cookieUtil; + private final OAuth2TestPageUtil oauth2TestPageUtil; @Value("${custom.oauth2.redirect-url}") private String frontendRedirectUrl; @@ -29,7 +30,6 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler @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 @@ -63,16 +63,24 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo member.getMemberId(), member.getLoginId(), accessToken, refreshToken )); } else { - // 개발 환경에서 프론트엔드 헬스체크 - String targetUrl = frontendRedirectUrl; - + // 프론트엔드 모드: 프론트엔드로 리다이렉트 또는 백엔드 성공 페이지 표시 if (isDevelopmentEnvironment() && !isFrontendAvailable()) { - log.warn("프론트엔드 서버({}})가 응답하지 않습니다. 백엔드 성공 페이지로 폴백합니다.", frontendRedirectUrl); - targetUrl = request.getContextPath() + BACKEND_SUCCESS_PAGE; + // 프론트엔드 서버가 없으면 백엔드 성공 페이지 HTML 직접 반환 + log.warn("프론트엔드 서버({})가 응답하지 않습니다. 백엔드 성공 페이지를 반환합니다.", frontendRedirectUrl); + response.setContentType("text/html;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + + String htmlContent = oauth2TestPageUtil.getSuccessPageHtml( + "OAuth2 로그인 성공", + "로그인에 성공했습니다!", + String.format("회원 ID: %d
이메일: %s", member.getMemberId(), member.getLoginId()) + ); + response.getWriter().write(htmlContent); + } else { + // 프론트엔드 서버로 리다이렉트 + log.info("OAuth2 로그인 완료, 프론트엔드로 리다이렉트: {}", frontendRedirectUrl); + getRedirectStrategy().sendRedirect(request, response, frontendRedirectUrl); } - - log.info("OAuth2 로그인 완료, 리다이렉트: {}", targetUrl); - getRedirectStrategy().sendRedirect(request, response, targetUrl); } } diff --git a/backend/src/main/resources/templates/oauth2-test/success-page.html b/backend/src/main/resources/templates/oauth2-test/success-page.html index 6446f820..aaaa50cc 100644 --- a/backend/src/main/resources/templates/oauth2-test/success-page.html +++ b/backend/src/main/resources/templates/oauth2-test/success-page.html @@ -87,6 +87,15 @@ transform: translateY(-2px); box-shadow: 0 4px 12px rgba(3, 199, 90, 0.4); } + .btn-withdraw { + background: #8b5cf6; + color: white; + } + .btn-withdraw:hover { + background: #7c3aed; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4); + } h1 { margin-bottom: 10px; } p { color: #6b7280; } @@ -98,20 +107,27 @@

{{TITLE}}

{{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 index 20440cc9..224106a9 100644 --- a/backend/src/main/resources/templates/oauth2-test/test-page.html +++ b/backend/src/main/resources/templates/oauth2-test/test-page.html @@ -172,59 +172,6 @@ .container { animation: slideUp 0.5s ease-out; } - - /* 팝업 로딩 오버레이 */ - .popup-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(102, 126, 234, 0.95) 0%, rgba(118, 75, 162, 0.95) 100%); - backdrop-filter: blur(10px); - z-index: 9999; - justify-content: center; - align-items: center; - animation: fadeIn 0.3s ease-out; - } - - .popup-content { - background: white; - border-radius: 20px; - padding: 50px 40px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - text-align: center; - max-width: 400px; - animation: slideUp 0.4s ease-out; - } - - .popup-content h2 { - font-size: 24px; - margin-bottom: 15px; - color: #333; - } - - .popup-content p { - font-size: 16px; - color: #6b7280; - margin-bottom: 30px; - } - - .spinner { - border: 4px solid #f3f4f6; - border-top: 4px solid #667eea; - border-radius: 50%; - width: 50px; - height: 50px; - animation: spin 1s linear infinite; - margin: 0 auto; - } - - @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } @@ -238,12 +185,6 @@

🔐 OAuth2 로그인 테스트

- - 📚 API 문서 @@ -262,28 +203,9 @@

🔐 OAuth2 로그인 테스트

- - -