Skip to content

Commit 86297b8

Browse files
committed
feat[OAuth]: 백엔드 소셜 로그인 테스트를 위한 코드 작성 및 리팩토링
1 parent 8b1229c commit 86297b8

File tree

14 files changed

+1567
-202
lines changed

14 files changed

+1567
-202
lines changed

backend/src/main/java/com/ai/lawyer/domain/auth/dto/OAuth2LoginResponse.java

Lines changed: 0 additions & 11 deletions
This file was deleted.

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

Lines changed: 208 additions & 47 deletions
Large diffs are not rendered by default.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.ai.lawyer.domain.member.dto;
2+
3+
import lombok.Builder;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@Builder
8+
public class LogoutResponse {
9+
private boolean success;
10+
private String message;
11+
private String oauth2LogoutUrl; // OAuth2 제공자 로그아웃 URL (없으면 null)
12+
13+
public static LogoutResponse of(String oauth2LogoutUrl) {
14+
return LogoutResponse.builder()
15+
.success(true)
16+
.message("로그아웃 성공")
17+
.oauth2LogoutUrl(oauth2LogoutUrl)
18+
.build();
19+
}
20+
}

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

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,6 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp
152152
return MemberResponse.from(member);
153153
}
154154

155-
@Transactional
156-
public void withdraw(Long memberId) {
157-
Member member = memberRepository.findById(memberId)
158-
.orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND));
159-
160-
memberRepository.delete(member);
161-
}
162155

163156
public MemberResponse getMemberById(Long memberId) {
164157
// Member 또는 OAuth2Member 조회
@@ -191,6 +184,28 @@ public String getLoginIdByMemberId(Long memberId) {
191184
return member.getLoginId();
192185
}
193186

187+
@Transactional
188+
public void deleteMember(String loginId) {
189+
// Member 또는 OAuth2Member 삭제
190+
java.util.Optional<Member> regularMember = memberRepository.findByLoginId(loginId);
191+
if (regularMember.isPresent()) {
192+
memberRepository.delete(regularMember.get());
193+
log.info("일반 회원 삭제 완료: loginId={}", loginId);
194+
return;
195+
}
196+
197+
if (oauth2MemberRepository != null) {
198+
java.util.Optional<OAuth2Member> oauth2Member = oauth2MemberRepository.findByLoginId(loginId);
199+
if (oauth2Member.isPresent()) {
200+
oauth2MemberRepository.delete(oauth2Member.get());
201+
log.info("OAuth2 회원 삭제 완료: loginId={}", loginId);
202+
return;
203+
}
204+
}
205+
206+
log.warn("삭제할 회원을 찾을 수 없습니다: loginId={}", loginId);
207+
}
208+
194209
public void sendCodeToEmailByLoginId(String loginId) {
195210
Member member = memberRepository.findByLoginId(loginId)
196211
.orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND_BY_LOGIN_ID));
Lines changed: 23 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package com.ai.lawyer.global.jwt;
22

3-
import com.ai.lawyer.domain.member.entity.MemberAdapter;
43
import com.ai.lawyer.domain.member.repositories.MemberRepository;
54
import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository;
65
import jakarta.servlet.FilterChain;
76
import jakarta.servlet.ServletException;
87
import jakarta.servlet.http.HttpServletRequest;
98
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.Getter;
1010
import lombok.extern.slf4j.Slf4j;
1111
import org.springframework.lang.Nullable;
1212
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -25,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
2525
private TokenProvider tokenProvider;
2626
private CookieUtil cookieUtil;
2727
private MemberRepository memberRepository;
28+
@Getter
2829
private OAuth2MemberRepository oauth2MemberRepository;
2930

3031
public JwtAuthenticationFilter() {
@@ -56,14 +57,8 @@ public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberReposit
5657
private static final String DEFAULT_ROLE = "USER";
5758

5859
// 로그 메시지 상수
59-
private static final String LOG_TOKEN_EXPIRED = "액세스 토큰 만료, 리프레시 토큰으로 갱신 시도";
60-
private static final String LOG_INVALID_TOKEN = "유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도";
61-
private static final String LOG_NO_REFRESH_TOKEN = "리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요";
62-
private static final String LOG_LOGIN_ID_EXTRACTION_FAILED = "loginId 추출 실패 - 쿠키 클리어";
63-
private static final String LOG_INVALID_REFRESH_TOKEN = "유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}";
64-
private static final String LOG_MEMBER_NOT_FOUND = "존재하지 않는 회원 - 쿠키 클리어: {}";
65-
private static final String LOG_TOKEN_REFRESH_SUCCESS = "토큰 자동 갱신 성공: {}";
66-
private static final String LOG_TOKEN_REFRESH_FAILED = "토큰 갱신 처리 실패: {}";
60+
private static final String LOG_TOKEN_EXPIRED = "액세스 토큰 만료 - 401 반환";
61+
private static final String LOG_INVALID_TOKEN = "유효하지 않은 액세스 토큰 - 401 반환";
6762
private static final String LOG_JWT_AUTH_ERROR = "JWT 인증 처리 중 오류 발생: {}";
6863
private static final String LOG_MEMBER_ID_EXTRACTION_FAILED = "토큰에서 memberId를 추출할 수 없습니다.";
6964
private static final String LOG_SET_AUTH_FAILED = "인증 정보 설정 실패: {}";
@@ -93,7 +88,7 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable
9388
processAuthentication(request, response);
9489
} catch (Exception e) {
9590
log.error(LOG_JWT_AUTH_ERROR, e.getMessage(), e);
96-
clearAuthenticationAndCookies(response);
91+
SecurityContextHolder.clearContext();
9792
}
9893
}
9994

@@ -115,21 +110,19 @@ private boolean shouldSkipFilter(HttpServletRequest request) {
115110
/**
116111
* 인증 프로세스를 처리합니다.
117112
*/
118-
private void processAuthentication(HttpServletRequest request, HttpServletResponse response) {
113+
private void processAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
119114
String accessToken = cookieUtil.getAccessTokenFromCookies(request);
120115

121116
if (accessToken != null) {
122-
handleAccessToken(request, response, accessToken);
123-
} else {
124-
// 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인
125-
handleTokenRefresh(request, response, null);
117+
handleAccessToken(response, accessToken);
126118
}
119+
// 액세스 토큰이 없는 경우 인증 처리하지 않음 (공개 API 허용)
127120
}
128121

129122
/**
130123
* 액세스 토큰을 검증하고 처리합니다.
131124
*/
132-
private void handleAccessToken(HttpServletRequest request, HttpServletResponse response, String accessToken) {
125+
private void handleAccessToken(HttpServletResponse response, String accessToken) throws IOException {
133126
TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken);
134127

135128
switch (validationResult) {
@@ -138,18 +131,28 @@ private void handleAccessToken(HttpServletRequest request, HttpServletResponse r
138131
setAuthentication(accessToken);
139132
break;
140133
case EXPIRED:
141-
// 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도
134+
// 만료된 액세스 토큰 - 401 반환
142135
log.info(LOG_TOKEN_EXPIRED);
143-
handleTokenRefresh(request, response, accessToken);
136+
sendUnauthorizedError(response);
144137
break;
145138
case INVALID:
146-
// 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인
139+
// 유효하지 않은 액세스 토큰 - 401 반환
147140
log.warn(LOG_INVALID_TOKEN);
148-
handleTokenRefresh(request, response, null);
141+
sendUnauthorizedError(response);
149142
break;
150143
}
151144
}
152145

146+
/**
147+
* 401 Unauthorized 응답을 반환합니다.
148+
*/
149+
private void sendUnauthorizedError(HttpServletResponse response) throws IOException {
150+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
151+
response.setContentType("application/json");
152+
response.setCharacterEncoding("UTF-8");
153+
response.getWriter().write("{\"error\":\"Unauthorized\",\"message\":\"토큰이 만료되었거나 유효하지 않습니다. /api/auth/refresh를 호출하여 토큰을 재발급 받으세요.\"}");
154+
}
155+
153156
/**
154157
* JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다.
155158
* @param token JWT 액세스 토큰
@@ -198,102 +201,4 @@ private String buildAuthority(String role) {
198201
return ROLE_PREFIX + (role != null ? role : DEFAULT_ROLE);
199202
}
200203

201-
/**
202-
* 리프레시 토큰을 사용하여 액세스 토큰을 갱신합니다.
203-
* RTR(Refresh Token Rotation) 패턴을 적용하여 새로운 토큰 쌍을 생성합니다.
204-
*/
205-
private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) {
206-
try {
207-
String refreshToken = cookieUtil.getRefreshTokenFromCookies(request);
208-
if (refreshToken == null) {
209-
log.info(LOG_NO_REFRESH_TOKEN);
210-
clearAuthenticationAndCookies(response);
211-
return;
212-
}
213-
214-
String loginId = extractLoginId(expiredAccessToken, refreshToken);
215-
if (loginId == null) {
216-
log.warn(LOG_LOGIN_ID_EXTRACTION_FAILED);
217-
clearAuthenticationAndCookies(response);
218-
return;
219-
}
220-
221-
if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) {
222-
log.info(LOG_INVALID_REFRESH_TOKEN, loginId);
223-
clearAuthenticationAndCookies(response);
224-
return;
225-
}
226-
227-
MemberAdapter member = findMemberByLoginId(loginId);
228-
if (member == null) {
229-
log.warn(LOG_MEMBER_NOT_FOUND, loginId);
230-
clearAuthenticationAndCookies(response);
231-
return;
232-
}
233-
234-
refreshTokensAndSetAuthentication(response, loginId, member);
235-
log.info(LOG_TOKEN_REFRESH_SUCCESS, loginId);
236-
237-
} catch (Exception e) {
238-
log.error(LOG_TOKEN_REFRESH_FAILED, e.getMessage(), e);
239-
clearAuthenticationAndCookies(response);
240-
}
241-
}
242-
243-
/**
244-
* loginId를 추출합니다 (만료된 토큰 또는 리프레시 토큰에서).
245-
*/
246-
private String extractLoginId(String expiredAccessToken, String refreshToken) {
247-
String loginId = null;
248-
if (expiredAccessToken != null) {
249-
loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken);
250-
}
251-
if (loginId == null) {
252-
loginId = tokenProvider.findUsernameByRefreshToken(refreshToken);
253-
}
254-
return loginId;
255-
}
256-
257-
/**
258-
* loginId로 회원 정보를 조회합니다 (Member 또는 OAuth2Member).
259-
*/
260-
private MemberAdapter findMemberByLoginId(String loginId) {
261-
MemberAdapter member = memberRepository.findByLoginId(loginId).orElse(null);
262-
263-
if (member == null && oauth2MemberRepository != null) {
264-
member = oauth2MemberRepository.findByLoginId(loginId).orElse(null);
265-
}
266-
267-
return member;
268-
}
269-
270-
/**
271-
* RTR 패턴으로 토큰을 갱신하고 인증을 설정합니다.
272-
*/
273-
private void refreshTokensAndSetAuthentication(HttpServletResponse response, String loginId, MemberAdapter member) {
274-
// RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제
275-
tokenProvider.deleteAllTokens(loginId);
276-
277-
// 새로운 액세스 토큰과 리프레시 토큰 생성
278-
String newAccessToken = tokenProvider.generateAccessToken(member);
279-
String newRefreshToken = tokenProvider.generateRefreshToken(member);
280-
281-
// 새로운 토큰들을 쿠키에 설정
282-
cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken);
283-
284-
// 새로운 액세스 토큰으로 인증 설정
285-
setAuthentication(newAccessToken);
286-
}
287-
288-
/**
289-
* 인증 정보와 쿠키를 모두 클리어합니다.
290-
*/
291-
private void clearAuthenticationAndCookies(HttpServletResponse response) {
292-
// Spring Security 인증 정보 클리어
293-
SecurityContextHolder.clearContext();
294-
295-
// 쿠키 클리어
296-
cookieUtil.clearTokenCookies(response);
297-
}
298-
299204
}

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
2020

2121
private final OAuth2MemberRepository oauth2MemberRepository;
22+
private final org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate;
2223

2324
@Override
2425
@Transactional
2526
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
2627
OAuth2User oAuth2User = super.loadUser(userRequest);
2728

2829
String registrationId = userRequest.getClientRegistration().getRegistrationId();
29-
log.info("OAuth2 로그인 시도: provider={}", registrationId);
30+
String accessToken = userRequest.getAccessToken().getTokenValue();
31+
32+
log.info("OAuth2 로그인 시도: provider={}, accessToken={}",
33+
registrationId, accessToken.substring(0, Math.min(10, accessToken.length())) + "...");
3034

3135
OAuth2UserInfo userInfo = getOAuth2UserInfo(registrationId, oAuth2User.getAttributes());
3236

@@ -49,9 +53,31 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic
4953

5054
oauth2MemberRepository.save(member);
5155

56+
// OAuth2 provider의 access token을 Redis에 저장 (연동 해제용)
57+
saveOAuth2ProviderAccessToken(userInfo.getEmail(), accessToken);
58+
59+
// Note: JWT 토큰은 OAuth2SuccessHandler에서 생성되어 Redis에 저장됩니다.
60+
5261
return new PrincipalDetails(member, oAuth2User.getAttributes());
5362
}
5463

64+
/**
65+
* OAuth2 provider의 access token을 Redis에 저장합니다.
66+
* 이 토큰은 소셜 연동 해제(회원 탈퇴) 시 사용됩니다.
67+
* @param loginId 회원 loginId (email)
68+
* @param accessToken OAuth2 provider access token
69+
*/
70+
private void saveOAuth2ProviderAccessToken(String loginId, String accessToken) {
71+
try {
72+
String key = "oauth2_provider_token:" + loginId;
73+
// 7일 TTL 설정 (refresh token과 동일한 기간)
74+
redisTemplate.opsForValue().set(key, accessToken, java.time.Duration.ofDays(7));
75+
log.info("OAuth2 provider access token 저장 완료: loginId={}", loginId);
76+
} catch (Exception e) {
77+
log.error("OAuth2 provider access token 저장 실패: loginId={}, error={}", loginId, e.getMessage());
78+
}
79+
}
80+
5581
private OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
5682
if ("kakao".equalsIgnoreCase(registrationId)) {
5783
return new KakaoUserInfo(attributes);

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

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,41 @@ public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler
2020
@Value("${custom.oauth2.failure-url}")
2121
private String failureUrl;
2222

23+
private final OAuth2TestPageUtil oauth2TestPageUtil;
24+
25+
public OAuth2FailureHandler(OAuth2TestPageUtil oauth2TestPageUtil) {
26+
this.oauth2TestPageUtil = oauth2TestPageUtil;
27+
}
28+
2329
@Override
2430
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
2531
AuthenticationException exception) throws IOException {
2632
log.error("OAuth2 로그인 실패: {}", exception.getMessage());
2733

28-
// 에러 메시지를 URL-safe하게 인코딩
29-
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
30-
String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
31-
32-
// 프론트엔드 실패 페이지로 리다이렉트
33-
String targetUrl = UriComponentsBuilder.fromUriString(failureUrl)
34-
.queryParam("error", encodedError)
35-
.build(true)
36-
.toUriString();
37-
log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl);
38-
39-
getRedirectStrategy().sendRedirect(request, response, targetUrl);
34+
// mode 파라미터 확인 (기본값: frontend)
35+
String mode = request.getParameter("mode");
36+
37+
if ("backend".equals(mode)) {
38+
// 백엔드 테스트 모드: HTML 에러 페이지 반환 (팝업 자동 닫기 포함)
39+
log.info("OAuth2 로그인 실패 (백엔드 테스트 모드)");
40+
response.setContentType("text/html;charset=UTF-8");
41+
response.setStatus(HttpServletResponse.SC_OK);
42+
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
43+
44+
String htmlContent = oauth2TestPageUtil.getFailurePageHtml(errorMessage);
45+
response.getWriter().write(htmlContent);
46+
} else {
47+
// 프론트엔드 모드: 리다이렉트
48+
String errorMessage = exception.getMessage() != null ? exception.getMessage() : "알 수 없는 오류";
49+
String encodedError = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
50+
51+
String targetUrl = UriComponentsBuilder.fromUriString(failureUrl)
52+
.queryParam("error", encodedError)
53+
.build(true)
54+
.toUriString();
55+
log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl);
56+
57+
getRedirectStrategy().sendRedirect(request, response, targetUrl);
58+
}
4059
}
4160
}

0 commit comments

Comments
 (0)