Skip to content
Closed
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
@@ -1,8 +1,8 @@
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.PrincipalDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
Expand Down Expand Up @@ -64,51 +64,19 @@ public ResponseEntity<MemberResponse> login(@Valid @RequestBody MemberLoginReque
}

@GetMapping("/oauth2/kakao")
@Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다.")
@Operation(summary = "11. 카카오 로그인", description = "카카오 OAuth2 로그인을 시작합니다. 프론트엔드 페이지로 리다이렉트됩니다.")
public void kakaoLogin(HttpServletResponse response) throws Exception {
log.info("카카오 로그인 요청");
response.sendRedirect("/oauth2/authorization/kakao");
}

@GetMapping("/oauth2/naver")
@Operation(summary = "12. 네이버 로그인", description = "네이버 OAuth2 로그인을 시작합니다.")
@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<OAuth2LoginResponse> oauth2LoginSuccess() {
log.info("OAuth2 로그인 성공 콜백");

OAuth2LoginResponse response = OAuth2LoginResponse.builder()
.success(true)
.message("소셜 로그인에 성공했습니다.")
.build();

return ResponseEntity.ok(response);
}

@GetMapping("/oauth2/callback/failure")
@Operation(summary = "15. OAuth2 로그인 실패 콜백", description = "OAuth2 로그인 실패 시 호출되는 콜백 엔드포인트입니다.")
@ApiResponses({
@ApiResponse(responseCode = "401", description = "로그인 실패"),
})
public ResponseEntity<OAuth2LoginResponse> 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 플로우 없이 소셜 로그인 결과를 시뮬레이션합니다.")
Expand All @@ -121,16 +89,42 @@ public ResponseEntity<MemberResponse> oauth2LoginTest(
}

@PostMapping("/logout")
@Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다.")
@Operation(summary = "09. 로그아웃", description = "현재 로그인된 사용자를 로그아웃합니다. 로컬 로그인과 소셜 로그인 모두 지원합니다.")
public ResponseEntity<Void> logout(Authentication authentication, HttpServletResponse response) {
if (authentication != null && authentication.getDetails() != null) {
String loginId = (String) authentication.getDetails();
memberService.logout(loginId, response);
log.info("로그아웃 완료: {}", loginId);
String loginId = null;

if (authentication != null && authentication.getPrincipal() != null) {
Object principal = authentication.getPrincipal();

// JWT 토큰 기반 인증 (로컬 로그인 & 소셜 로그인 모두)
if (principal instanceof Long memberId) {
// memberId로 조회
loginId = memberService.getLoginIdByMemberId(memberId);
log.info("memberId로 로그아웃: memberId={}, loginId={}", memberId, loginId);
}
// PrincipalDetails (OAuth2 또는 로컬 로그인)
else if (principal 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());
}
// authentication.getDetails() 사용 (기존 방식)
else if (authentication.getDetails() instanceof String) {
loginId = (String) authentication.getDetails();
log.info("Details로 로그아웃: loginId={}", loginId);
}
}

// 로그아웃 처리 (Redis에서 토큰 삭제 + 쿠키 삭제)
memberService.logout(loginId != null ? loginId : "", response);

if (loginId != null) {
log.info("로그아웃 완료: loginId={}", loginId);
} else {
memberService.logout("", response);
log.info("인증 정보 없이 로그아웃 완료");
log.info("인증 정보 없이 로그아웃 완료 (쿠키만 삭제)");
}

return ResponseEntity.ok().build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,22 @@ 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();
}

public void sendCodeToEmailByLoginId(String loginId) {
Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(() -> new IllegalArgumentException(ERR_MEMBER_NOT_FOUND_BY_LOGIN_ID));
Expand Down
37 changes: 21 additions & 16 deletions backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ 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);
Expand All @@ -94,6 +102,16 @@ 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/");
}

/**
* 인증 프로세스를 처리합니다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
@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;

@Override
Expand All @@ -29,12 +29,13 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo
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로 설정하여 이미 인코딩된 값을 사용
.build(true)
.toUriString();
log.info("OAuth2 로그인 실패, 프론트엔드 실패 페이지로 리다이렉트: {}", targetUrl);

log.info("OAuth2 로그인 실패, 백엔드 콜백으로 리다이렉트: {}", targetUrl);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
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;

Expand All @@ -22,7 +21,7 @@ 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}")
@Value("${custom.oauth2.redirect-url}")
private String redirectUrl;

@Override
Expand All @@ -41,12 +40,11 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
// 쿠키에 토큰 설정
cookieUtil.setTokenCookies(response, accessToken, refreshToken);

// 백엔드 콜백 엔드포인트로 리다이렉트
String targetUrl = UriComponentsBuilder.fromUriString(redirectUrl)
.build()
.toUriString();
log.info("JWT 토큰 생성 완료 및 쿠키 설정 완료");

log.info("OAuth2 로그인 완료, 백엔드 콜백으로 리다이렉트: {}", targetUrl);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
// 프론트엔드 성공 페이지로 리다이렉트
log.info("OAuth2 로그인 완료, 프론트엔드 성공 페이지로 리다이렉트: {}", redirectUrl);

getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
Loading
Loading