Skip to content

Commit 134c028

Browse files
authored
Merge pull request #256 from asowjdan/feat/oauth
Fix[OAuth]: 백엔드 소셜 로그인 및 jwt 토큰 생성 로직 및 html 수정
2 parents 5e89c73 + c3a896a commit 134c028

File tree

9 files changed

+185
-216
lines changed

9 files changed

+185
-216
lines changed

backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -107,28 +107,48 @@ public ResponseEntity<String> oauth2TestPage() {
107107

108108
@GetMapping("/oauth2/success-page")
109109
@Operation(summary = "15. OAuth2 로그인 성공 페이지 (백엔드 테스트용)", description = "프론트엔드 없이 백엔드에서 OAuth2 로그인 결과를 확인할 수 있는 페이지입니다.")
110-
public ResponseEntity<String> oauth2SuccessPage(Authentication authentication) {
111-
if (authentication == null || authentication.getPrincipal() == null) {
110+
public ResponseEntity<String> oauth2SuccessPage(Authentication authentication, HttpServletRequest request) {
111+
String loginId = null;
112+
Long memberId = null;
113+
114+
// 1. Authentication 객체에서 인증 정보 추출 시도 (OAuth2 직접 로그인)
115+
if (authentication != null && authentication.getPrincipal() != null) {
116+
Object principal = authentication.getPrincipal();
117+
118+
if (principal instanceof Long) {
119+
memberId = (Long) principal;
120+
loginId = (String) authentication.getDetails();
121+
} else if (principal instanceof PrincipalDetails principalDetails) {
122+
com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember();
123+
loginId = member.getLoginId();
124+
memberId = member.getMemberId();
125+
}
126+
}
127+
128+
// 2. 쿠키에서 JWT 토큰 추출 시도 (리다이렉트 후)
129+
if (loginId == null || memberId == null) {
130+
String accessToken = extractAccessTokenFromRequest(request);
131+
if (accessToken != null) {
132+
try {
133+
loginId = memberService.extractLoginIdFromToken(accessToken);
134+
memberId = memberService.extractMemberIdFromToken(accessToken);
135+
log.info("쿠키에서 인증 정보 추출 성공: loginId={}, memberId={}", loginId, memberId);
136+
} catch (Exception e) {
137+
log.warn("쿠키에서 인증 정보 추출 실패: {}", e.getMessage());
138+
}
139+
}
140+
}
141+
142+
// 3. 인증 정보 확인
143+
if (loginId == null || memberId == null) {
144+
log.warn("OAuth2 성공 페이지 접근 실패: 인증 정보 없음");
112145
return ResponseEntity.ok(buildHtmlResponse(
113146
"OAuth2 로그인 실패",
114147
"인증 정보가 없습니다.",
115-
null
148+
"쿠키에 토큰이 없거나 유효하지 않습니다."
116149
));
117150
}
118151

119-
Object principal = authentication.getPrincipal();
120-
String loginId = null;
121-
Long memberId = null;
122-
123-
if (principal instanceof Long) {
124-
memberId = (Long) principal;
125-
loginId = (String) authentication.getDetails();
126-
} else if (principal instanceof PrincipalDetails principalDetails) {
127-
com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember();
128-
loginId = member.getLoginId();
129-
memberId = member.getMemberId();
130-
}
131-
132152
return ResponseEntity.ok(buildHtmlResponse(
133153
"OAuth2 로그인 성공",
134154
"로그인에 성공했습니다!",

backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ public String extractLoginIdFromToken(String token) {
263263
return tokenProvider.getLoginIdFromToken(token);
264264
}
265265

266+
public Long extractMemberIdFromToken(String token) {
267+
return tokenProvider.getMemberIdFromToken(token);
268+
}
269+
266270
@Transactional
267271
public MemberResponse oauth2LoginTest(OAuth2LoginTestRequest request, HttpServletResponse response) {
268272
if (oauth2MemberRepository == null) {

backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ public class CookieUtil {
2323

2424
// 쿠키 보안 설정 상수
2525
private static final boolean HTTP_ONLY = true;
26-
private static final boolean SECURE_IN_PRODUCTION = true; // 운영환경에서는 true로 변경 (HTTPS)
26+
private static final boolean SECURE_IN_PRODUCTION = false; // 개발환경에서는 false (HTTP), 운영환경에서는 true로 변경 (HTTPS)
2727
private static final String COOKIE_PATH = "/";
28-
private static final String SAME_SITE = "None"; // None, Lax, Strict 중 선택
28+
private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용
2929
private static final int COOKIE_EXPIRE_IMMEDIATELY = 0;
3030

3131
public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) {

backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2FailureHandler.java

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import org.springframework.web.util.UriComponentsBuilder;
1111

1212
import java.io.IOException;
13+
import java.net.HttpURLConnection;
14+
import java.net.URI;
1315
import java.net.URLEncoder;
1416
import java.nio.charset.StandardCharsets;
1517

@@ -20,8 +22,13 @@ public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler
2022
@Value("${custom.oauth2.failure-url}")
2123
private String failureUrl;
2224

25+
@Value("${spring.profiles.active:dev}")
26+
private String activeProfile;
27+
2328
private final OAuth2TestPageUtil oauth2TestPageUtil;
2429

30+
private static final int HEALTH_CHECK_TIMEOUT = 2000; // 2초
31+
2532
public OAuth2FailureHandler(OAuth2TestPageUtil oauth2TestPageUtil) {
2633
this.oauth2TestPageUtil = oauth2TestPageUtil;
2734
}
@@ -31,30 +38,76 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo
3138
AuthenticationException exception) throws IOException {
3239
log.error("OAuth2 로그인 실패: {}", exception.getMessage());
3340

34-
// mode 파라미터 확인 (기본값: frontend)
41+
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
42+
43+
// mode 파라미터 확인
3544
String mode = request.getParameter("mode");
3645

3746
if ("backend".equals(mode)) {
38-
// 백엔드 테스트 모드: HTML 에러 페이지 반환 (팝업 자동 닫기 포함)
47+
// 백엔드 테스트 모드: HTML 에러 페이지 반환
3948
log.info("OAuth2 로그인 실패 (백엔드 테스트 모드)");
4049
response.setContentType("text/html;charset=UTF-8");
4150
response.setStatus(HttpServletResponse.SC_OK);
42-
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
4351

4452
String htmlContent = oauth2TestPageUtil.getFailurePageHtml(errorMessage);
4553
response.getWriter().write(htmlContent);
4654
} else {
47-
// 프론트엔드 모드: 리다이렉트
48-
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
49-
String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
55+
// 프론트엔드 모드: 프론트엔드로 리다이렉트 또는 백엔드 실패 페이지 표시
56+
if (isDevelopmentEnvironment() && !isFrontendAvailable()) {
57+
// 프론트엔드 서버가 없으면 백엔드 실패 페이지 HTML 직접 반환
58+
log.warn("프론트엔드 서버({})가 응답하지 않습니다. 백엔드 실패 페이지를 반환합니다.", failureUrl);
59+
response.setContentType("text/html;charset=UTF-8");
60+
response.setStatus(HttpServletResponse.SC_OK);
61+
62+
String htmlContent = oauth2TestPageUtil.getFailurePageHtml(errorMessage);
63+
response.getWriter().write(htmlContent);
64+
} else {
65+
// 프론트엔드 서버로 리다이렉트
66+
String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
67+
String targetUrl = UriComponentsBuilder.fromUriString(failureUrl)
68+
.queryParam("error", encodedError)
69+
.build(true)
70+
.toUriString();
71+
log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl);
72+
73+
getRedirectStrategy().sendRedirect(request, response, targetUrl);
74+
}
75+
}
76+
}
77+
78+
/**
79+
* 개발 환경인지 확인
80+
*/
81+
private boolean isDevelopmentEnvironment() {
82+
return "dev".equals(activeProfile) || "local".equals(activeProfile);
83+
}
84+
85+
/**
86+
* 프론트엔드 서버가 동작 중인지 확인
87+
*/
88+
private boolean isFrontendAvailable() {
89+
try {
90+
URI uri = URI.create(failureUrl);
91+
// 쿼리 파라미터를 제거하고 베이스 URL만 추출
92+
String baseUrl = uri.getScheme() + "://" + uri.getHost() +
93+
(uri.getPort() != -1 ? ":" + uri.getPort() : "");
94+
95+
HttpURLConnection connection = (HttpURLConnection) URI.create(baseUrl).toURL().openConnection();
96+
connection.setRequestMethod("GET");
97+
connection.setConnectTimeout(HEALTH_CHECK_TIMEOUT);
98+
connection.setReadTimeout(HEALTH_CHECK_TIMEOUT);
99+
connection.connect();
50100

51-
String targetUrl = UriComponentsBuilder.fromUriString(failureUrl)
52-
.queryParam("error", encodedError)
53-
.build(true)
54-
.toUriString();
55-
log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl);
101+
int responseCode = connection.getResponseCode();
102+
connection.disconnect();
56103

57-
getRedirectStrategy().sendRedirect(request, response, targetUrl);
104+
// 200-299 또는 404도 서버가 살아있는 것으로 간주
105+
boolean isAvailable = (responseCode >= 200 && responseCode < 300) || responseCode == 404;
106+
log.debug("프론트엔드 헬스체크: {} - 응답코드 {}", baseUrl, responseCode);
107+
return isAvailable;
108+
} catch (Exception e) {
109+
log.debug("프론트엔드 헬스체크 실패: {}", e.getMessage());
110+
return false;
58111
}
59112
}
60113
}

backend/src/main/java/com/ai/lawyer/global/oauth/OAuth2SuccessHandler.java

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler
2222

2323
private final TokenProvider tokenProvider;
2424
private final CookieUtil cookieUtil;
25+
private final OAuth2TestPageUtil oauth2TestPageUtil;
2526

2627
@Value("${custom.oauth2.redirect-url}")
2728
private String frontendRedirectUrl;
2829

2930
@Value("${spring.profiles.active:dev}")
3031
private String activeProfile;
3132

32-
private static final String BACKEND_SUCCESS_PAGE = "/api/auth/oauth2/success-page";
3333
private static final int HEALTH_CHECK_TIMEOUT = 2000; // 2초
3434

3535
@Override
@@ -63,16 +63,24 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
6363
member.getMemberId(), member.getLoginId(), accessToken, refreshToken
6464
));
6565
} else {
66-
// 개발 환경에서 프론트엔드 헬스체크
67-
String targetUrl = frontendRedirectUrl;
68-
66+
// 프론트엔드 모드: 프론트엔드로 리다이렉트 또는 백엔드 성공 페이지 표시
6967
if (isDevelopmentEnvironment() && !isFrontendAvailable()) {
70-
log.warn("프론트엔드 서버({}})가 응답하지 않습니다. 백엔드 성공 페이지로 폴백합니다.", frontendRedirectUrl);
71-
targetUrl = request.getContextPath() + BACKEND_SUCCESS_PAGE;
68+
// 프론트엔드 서버가 없으면 백엔드 성공 페이지 HTML 직접 반환
69+
log.warn("프론트엔드 서버({})가 응답하지 않습니다. 백엔드 성공 페이지를 반환합니다.", frontendRedirectUrl);
70+
response.setContentType("text/html;charset=UTF-8");
71+
response.setStatus(HttpServletResponse.SC_OK);
72+
73+
String htmlContent = oauth2TestPageUtil.getSuccessPageHtml(
74+
"OAuth2 로그인 성공",
75+
"로그인에 성공했습니다!",
76+
String.format("회원 ID: %d<br>이메일: %s", member.getMemberId(), member.getLoginId())
77+
);
78+
response.getWriter().write(htmlContent);
79+
} else {
80+
// 프론트엔드 서버로 리다이렉트
81+
log.info("OAuth2 로그인 완료, 프론트엔드로 리다이렉트: {}", frontendRedirectUrl);
82+
getRedirectStrategy().sendRedirect(request, response, frontendRedirectUrl);
7283
}
73-
74-
log.info("OAuth2 로그인 완료, 리다이렉트: {}", targetUrl);
75-
getRedirectStrategy().sendRedirect(request, response, targetUrl);
7684
}
7785
}
7886

backend/src/main/resources/templates/oauth2-test/success-page.html

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,15 @@
8787
transform: translateY(-2px);
8888
box-shadow: 0 4px 12px rgba(3, 199, 90, 0.4);
8989
}
90+
.btn-withdraw {
91+
background: #8b5cf6;
92+
color: white;
93+
}
94+
.btn-withdraw:hover {
95+
background: #7c3aed;
96+
transform: translateY(-2px);
97+
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.4);
98+
}
9099
h1 { margin-bottom: 10px; }
91100
p { color: #6b7280; }
92101
</style>
@@ -98,20 +107,27 @@ <h1 class="{{CLASS}}">{{TITLE}}</h1>
98107
{{DETAILS}}
99108
<div class="button-group">
100109
<button class="btn btn-logout" onclick="logout()">로그아웃</button>
110+
<button class="btn btn-withdraw" onclick="withdraw()">⚠️ 회원 탈퇴</button>
101111
<a href="/swagger-ui.html" class="btn btn-docs">API 문서</a>
102112
<a href="/api/auth/oauth2/test-page" class="btn btn-kakao">테스트 페이지</a>
103113
<button class="btn btn-naver" onclick="closeWindow()">창 닫기</button>
104114
</div>
105115
</div>
106116
<script>
107-
// 팝업 창인지 확인하고 자동으로 닫기 제안
117+
// 팝업 창인지 확인하고 자동으로 닫기
108118
window.addEventListener('DOMContentLoaded', () => {
109119
if (window.opener && !window.opener.closed) {
110-
// 팝업 창에서 열린 경우
111-
const autoClose = confirm('로그인에 성공했습니다!\n\n이 창을 닫으시겠습니까?\n(메인 창에서 로그인 상태가 갱신됩니다)');
112-
if (autoClose) {
113-
window.close();
120+
// 팝업 창에서 열린 경우: 부모 창을 success-page로 리다이렉트하고 팝업 닫기
121+
try {
122+
window.opener.location.href = '/api/auth/oauth2/success-page';
123+
} catch (e) {
124+
console.error('부모 창 리다이렉트 실패:', e);
114125
}
126+
127+
// 1초 후 자동으로 팝업 닫기
128+
setTimeout(() => {
129+
window.close();
130+
}, 1000);
115131
}
116132
});
117133

@@ -156,6 +172,34 @@ <h1 class="{{CLASS}}">{{TITLE}}</h1>
156172
alert('로그아웃 중 오류가 발생했습니다.');
157173
}
158174
}
175+
176+
async function withdraw() {
177+
if (!confirm('정말로 회원 탈퇴를 진행하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.\n⚠️ 모든 회원 정보가 삭제되며, OAuth2 연동도 해제됩니다.')) {
178+
return;
179+
}
180+
181+
try {
182+
const response = await fetch('/api/auth/withdraw', {
183+
method: 'DELETE',
184+
credentials: 'include',
185+
headers: {
186+
'Content-Type': 'application/json'
187+
}
188+
});
189+
190+
if (response.ok) {
191+
const data = await response.json();
192+
alert('회원 탈퇴 완료!\n\n' + (data.oauth2Unlinked ? 'OAuth2 연동 해제 완료' : '로컬 회원 삭제 완료'));
193+
window.location.href = '/api/auth/oauth2/test-page';
194+
} else {
195+
const errorData = await response.json();
196+
alert('회원 탈퇴 실패: ' + (errorData.message || '알 수 없는 오류'));
197+
}
198+
} catch (error) {
199+
console.error('회원 탈퇴 에러:', error);
200+
alert('회원 탈퇴 중 오류가 발생했습니다.');
201+
}
202+
}
159203
</script>
160204
</body>
161205
</html>

0 commit comments

Comments
 (0)