Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -107,28 +107,48 @@ public ResponseEntity<String> oauth2TestPage() {

@GetMapping("/oauth2/success-page")
@Operation(summary = "15. OAuth2 로그인 성공 페이지 (백엔드 테스트용)", description = "프론트엔드 없이 백엔드에서 OAuth2 로그인 결과를 확인할 수 있는 페이지입니다.")
public ResponseEntity<String> oauth2SuccessPage(Authentication authentication) {
if (authentication == null || authentication.getPrincipal() == null) {
public ResponseEntity<String> 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 로그인 성공",
"로그인에 성공했습니다!",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ 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;

@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
Expand Down Expand Up @@ -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<br>이메일: %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);
}
}

Expand Down
54 changes: 49 additions & 5 deletions backend/src/main/resources/templates/oauth2-test/success-page.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
Expand All @@ -98,20 +107,27 @@ <h1 class="{{CLASS}}">{{TITLE}}</h1>
{{DETAILS}}
<div class="button-group">
<button class="btn btn-logout" onclick="logout()">로그아웃</button>
<button class="btn btn-withdraw" onclick="withdraw()">⚠️ 회원 탈퇴</button>
<a href="/swagger-ui.html" class="btn btn-docs">API 문서</a>
<a href="/api/auth/oauth2/test-page" class="btn btn-kakao">테스트 페이지</a>
<button class="btn btn-naver" onclick="closeWindow()">창 닫기</button>
</div>
</div>
<script>
// 팝업 창인지 확인하고 자동으로 닫기 제안
// 팝업 창인지 확인하고 자동으로 닫기
window.addEventListener('DOMContentLoaded', () => {
if (window.opener && !window.opener.closed) {
// 팝업 창에서 열린 경우
const autoClose = confirm('로그인에 성공했습니다!\n\n이 창을 닫으시겠습니까?\n(메인 창에서 로그인 상태가 갱신됩니다)');
if (autoClose) {
window.close();
// 팝업 창에서 열린 경우: 부모 창을 success-page로 리다이렉트하고 팝업 닫기
try {
window.opener.location.href = '/api/auth/oauth2/success-page';
} catch (e) {
console.error('부모 창 리다이렉트 실패:', e);
}

// 1초 후 자동으로 팝업 닫기
setTimeout(() => {
window.close();
}, 1000);
}
});

Expand Down Expand Up @@ -156,6 +172,34 @@ <h1 class="{{CLASS}}">{{TITLE}}</h1>
alert('로그아웃 중 오류가 발생했습니다.');
}
}

async function withdraw() {
if (!confirm('정말로 회원 탈퇴를 진행하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.\n⚠️ 모든 회원 정보가 삭제되며, OAuth2 연동도 해제됩니다.')) {
return;
}

try {
const response = await fetch('/api/auth/withdraw', {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
});

if (response.ok) {
const data = await response.json();
alert('회원 탈퇴 완료!\n\n' + (data.oauth2Unlinked ? 'OAuth2 연동 해제 완료' : '로컬 회원 삭제 완료'));
window.location.href = '/api/auth/oauth2/test-page';
} else {
const errorData = await response.json();
alert('회원 탈퇴 실패: ' + (errorData.message || '알 수 없는 오류'));
}
} catch (error) {
console.error('회원 탈퇴 에러:', error);
alert('회원 탈퇴 중 오류가 발생했습니다.');
}
}
</script>
</body>
</html>
Loading