-
Notifications
You must be signed in to change notification settings - Fork 2
[FEAT] 로그아웃 구현 #224
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
The head ref may contain hidden characters: "feat/#74/\uD68C\uC6D0\uD0C8\uD1F4-\uAD6C\uD604"
[FEAT] 로그아웃 구현 #224
Changes from 10 commits
46a0fc6
959a3af
4b7e04f
24f9ed8
788ae78
c0e64d1
cd9b1e4
ee9f1f4
9594d24
a04c985
f08e3e4
ae88d1e
21d0c70
52004f0
1b336eb
d849aaa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.tavemakers.surf.domain.login.controller; | ||
|
|
||
| import com.tavemakers.surf.domain.login.auth.service.RefreshTokenService; | ||
| import com.tavemakers.surf.global.common.response.ApiResponse; | ||
| import com.tavemakers.surf.global.jwt.JwtService; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.security.core.context.SecurityContextHolder; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @Tag(name = "인증", description = "로그인/로그아웃") | ||
| @RestController | ||
| @RequiredArgsConstructor | ||
| public class LogoutController { | ||
|
|
||
| private final JwtService jwtService; | ||
| private final RefreshTokenService refreshTokenService; | ||
|
|
||
| @Operation(summary = "로그아웃", description = "현재 디바이스의 refreshToken을 무효화하고 쿠키를 삭제합니다.") | ||
| @PostMapping("/auth/logout") | ||
| public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response) { | ||
|
|
||
| jwtService.extractRefreshToken(request).ifPresent(refreshToken -> { | ||
| if (jwtService.isTokenValid(refreshToken)) { | ||
| Long memberId = jwtService.extractMemberId(refreshToken).orElse(null); | ||
| String deviceId = jwtService.extractDeviceId(refreshToken).orElse(null); | ||
| if (memberId != null && deviceId != null) { | ||
| refreshTokenService.invalidate(memberId, deviceId); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| // 쿠키 삭제는 항상 수행 | ||
| jwtService.clearRefreshTokenCookie(response); | ||
|
|
||
| // 컨텍스트 정리 | ||
| SecurityContextHolder.clearContext(); | ||
|
|
||
| return ApiResponse.response(HttpStatus.NO_CONTENT, "로그아웃 완료", null); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,6 @@ | |
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| import org.springframework.security.core.context.SecurityContext; | ||
| import org.springframework.security.core.context.SecurityContextHolder; | ||
|
|
@@ -23,91 +22,62 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { | |
|
|
||
| private final JwtService jwtService; | ||
| private final MemberRepository memberRepository; | ||
| private final RedisTemplate<String, String> redisTemplate; // 없으면 null 주입 가능 | ||
|
|
||
| private static final String LOGOUT_URL = "/auth/logout"; | ||
|
|
||
| @Override | ||
| protected boolean shouldNotFilter(HttpServletRequest request) { | ||
| String uri = request.getRequestURI(); | ||
| return uri.startsWith("/login/") | ||
| || uri.equals(LOGOUT_URL); | ||
| || uri.equals("/auth/logout") | ||
| || uri.equals("/auth/refresh") | ||
| || uri.startsWith("/swagger-ui") | ||
| || uri.startsWith("/v3/api-docs"); | ||
| } | ||
|
|
||
| @Override | ||
| protected void doFilterInternal(HttpServletRequest request, | ||
| HttpServletResponse response, | ||
| FilterChain chain | ||
| protected void doFilterInternal( | ||
| HttpServletRequest request, | ||
| HttpServletResponse response, | ||
| FilterChain chain | ||
| ) throws ServletException, IOException { | ||
|
|
||
| final String uri = request.getRequestURI(); | ||
|
|
||
| String refreshToken = jwtService.extractRefreshToken(request).orElse(null); | ||
| String accessToken = jwtService.extractAccessTokenFromHeader(request) | ||
| .filter(jwtService::isTokenValid) | ||
| .orElse(null); | ||
|
|
||
| log.debug("URI: {}, accessToken? {}, refreshToken? {}", uri, accessToken != null, refreshToken != null); | ||
| String accessToken = jwtService.extractAccessTokenFromHeader(request).orElse(null); | ||
|
|
||
| // 1) 토큰이 아예 없는 경우 → 그냥 통과 (카카오 콜백 포함) | ||
| if (accessToken == null && refreshToken == null) { | ||
| // 1) AT가 아예 없으면 → 익명 통과 (인가 여부는 SecurityConfig가 판단) | ||
| if (accessToken == null) { | ||
| chain.doFilter(request, response); | ||
| return; | ||
| } | ||
|
|
||
| // 2) 둘 다 있는 경우: 액세스 토큰 블랙리스트만 체크하고 통과 | ||
| if (accessToken != null && refreshToken != null) { | ||
| log.info("토큰 둘다 있는 경우"); | ||
| if (isBlacklisted(accessToken)) { | ||
| unauthorized(response); | ||
| return; | ||
| } | ||
| authenticateUser(accessToken, request); | ||
| chain.doFilter(request, response); | ||
| // 2) AT가 있는데 유효하지 않으면 → 401 (프론트가 /auth/refresh 후 재시도) | ||
| if (!jwtService.isTokenValid(accessToken)) { | ||
| unauthorized(response, "Invalid or expired access token"); | ||
| return; | ||
| } | ||
|
|
||
| // 3) 액세스만 있는 경우: 블랙리스트 체크 후 인증 주입 | ||
| if (accessToken != null) { | ||
| log.info("Access만 있는 경우"); | ||
| if (isBlacklisted(accessToken)) { | ||
| unauthorized(response); | ||
| return; | ||
| } | ||
| authenticateUser(accessToken, request); | ||
| chain.doFilter(request, response); | ||
| return; | ||
| } | ||
|
|
||
| // 4) 액세스 없음 + 리프레시만 있는 경우: 재발급 후 401로 재시도 유도 | ||
| log.info("refresh만 있는 경우"); | ||
| response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
| response.setContentType("application/json;charset=UTF-8"); | ||
| response.getWriter().write("{\"message\":\"Access token이 필요합니다. /auth/refresh로 재발급하세요.\"}"); | ||
| // 3) 유효하면 인증 주입 후 통과 | ||
| authenticateUser(accessToken, request); | ||
| chain.doFilter(request, response); | ||
| } | ||
|
|
||
| /** AccessToken → memberId → Member 로드 → SecurityContext 주입 */ | ||
| private void authenticateUser(String accessToken, HttpServletRequest req) { | ||
| jwtService.extractMemberId(accessToken).flatMap(memberRepository::findById).ifPresent(member -> { | ||
| CustomUserDetails principal = new CustomUserDetails(member); | ||
| UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( | ||
| principal, null, principal.getAuthorities()); | ||
| auth | ||
| .setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); | ||
|
|
||
| SecurityContext context = SecurityContextHolder.createEmptyContext(); | ||
| context.setAuthentication(auth); | ||
| SecurityContextHolder.setContext(context); | ||
| jwtService.extractMemberId(accessToken) | ||
| .flatMap(memberRepository::findById) | ||
| .ifPresent(member -> { | ||
| CustomUserDetails principal = new CustomUserDetails(member); | ||
| UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( | ||
| principal, null, principal.getAuthorities()); | ||
| auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req)); | ||
|
|
||
| SecurityContext context = SecurityContextHolder.createEmptyContext(); | ||
| context.setAuthentication(auth); | ||
| SecurityContextHolder.setContext(context); | ||
| }); | ||
| } | ||
|
Comment on lines
65
to
79
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 회원 조회 실패 시 silent failure가 발생할 수 있습니다.
원인: 권장 개선안:
🛠️ 권장 수정 코드 /** AccessToken → memberId → Member 로드 → SecurityContext 주입 */
- private void authenticateUser(String accessToken, HttpServletRequest req) {
- jwtService.extractMemberId(accessToken)
- .flatMap(memberRepository::findById)
- .ifPresent(member -> {
- CustomUserDetails principal = new CustomUserDetails(member);
- UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
- principal, null, principal.getAuthorities());
- auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
-
- SecurityContext context = SecurityContextHolder.createEmptyContext();
- context.setAuthentication(auth);
- SecurityContextHolder.setContext(context);
- });
- }
+ private boolean authenticateUser(String accessToken, HttpServletRequest req) {
+ return jwtService.extractMemberId(accessToken)
+ .flatMap(memberRepository::findById)
+ .map(member -> {
+ CustomUserDetails principal = new CustomUserDetails(member);
+ UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
+ principal, null, principal.getAuthorities());
+ auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(req));
+
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(auth);
+ SecurityContextHolder.setContext(context);
+ return true;
+ })
+ .orElseGet(() -> {
+ log.warn("Member not found for valid token");
+ return false;
+ });
+ }
- // 3) 유효하면 인증 주입 후 통과
- authenticateUser(accessToken, request);
- chain.doFilter(request, response);
+ // 3) 유효하면 인증 주입 후 통과
+ if (!authenticateUser(accessToken, request)) {
+ unauthorized(response, "Member not found");
+ return;
+ }
+ chain.doFilter(request, response);Spring Security 공식 문서의 Authentication Architecture에서 인증 실패 처리 패턴을 참고하실 수 있습니다.
|
||
|
|
||
| private boolean isBlacklisted(String accessToken) { | ||
| if (redisTemplate == null) return false; | ||
| return redisTemplate.opsForValue().get("blacklist:" + accessToken) != null; | ||
| } | ||
|
|
||
| private void unauthorized(HttpServletResponse res) throws IOException { | ||
| private void unauthorized(HttpServletResponse res, String message) throws IOException { | ||
| res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
| res.getWriter().write("This access token is blacklisted (logged out)."); | ||
| res.setContentType("application/json;charset=UTF-8"); | ||
| res.getWriter().write("{\"message\":\"" + message + "\"}"); | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.