Skip to content

Commit b2c25ed

Browse files
authored
fix: 로그아웃 로직 중복 수정 (#115)
* feat: Redis 설정 * feat: 소셜로그인, 재발급 시 refresh token을 redis에 저장/삭제/조회 * feat: 로그아웃, 회원 탈퇴 시 Redis에서 Refresh Token 삭제 * refactor: jwtFilter 중복 코드 제거 * feat: 블랙리스트 토큰 여부 적용 * feat: 블랙리스트 토큰 저장 구현 * feat: 탈퇴 시 블랙리스트 토큰 저장 적용 * test: 탈퇴 시 블랙리스트 토큰 저장 적용 * fix: 누락된 문서 추가 * refactor: 로그아웃, 탈퇴 시 사용 API 통일 * 새로ì�ci: 개발계용 dockerfile 생성 * ci: 개발계 배포 스크립트 * test: Redis 미기동으로 테스트 실패 방지 * ci: 배포 스크립트 수정 * feat: RedisConfig 삭제 (Spring Boot 자동설정 사용) * test: redis mocking * chore: 에러코드 수정 * chore: 불필요 요소 삭제 * fix: JWT 로그아웃 필터 유형 변경 * test: JWT 로그아웃 필터 관련 테스트 코드 수정 * fix: 트랜잭션 전파 수정 * feat: 에러코드 추가 * chore: 불필요 요소 삭제 * chore: 주석 수정
1 parent ab4dbb9 commit b2c25ed

File tree

16 files changed

+147
-69
lines changed

16 files changed

+147
-69
lines changed

capturecat-core/compose.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
services:
2+
dev-capturecat-db:
3+
image: postgres
4+
environment:
5+
POSTGRES_DB: capturecat
6+
POSTGRES_USER: capturecat
7+
POSTGRES_PASSWORD: capturecat77
8+
MYSQL_ROOT_PASSWORD:
9+
MYSQL_DATABASE:
10+
volumes:
11+
- /home/ubuntu/capturecat/database/docker-postgresql/postgresql_data:/var/lib/postgresql/data
12+
ports:
13+
- 5432:5432
14+
healthcheck:
15+
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1 -p 5432"]
16+
interval: 5s
17+
retries: 10
18+
dev-capturecat-cache-server:
19+
image: redis
20+
command: ["redis-server", "--requirepass", "capturecat77"]
21+
ports:
22+
- 6379:6379
23+
healthcheck:
24+
test: [ "CMD", "redis-cli", "ping" ]
25+
interval: 5s
26+
retries: 10

capturecat-core/src/main/java/com/capturecat/core/config/SecurityConfig.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
4646
http.csrf(AbstractHttpConfigurer::disable)
4747
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
4848
.formLogin(AbstractHttpConfigurer::disable)
49+
.logout(AbstractHttpConfigurer::disable)
4950
.httpBasic(AbstractHttpConfigurer::disable)
50-
.addFilterBefore(new JwtFilter(jwtUtil, tokenService), JwtLoginFilter.class)
51+
.addFilterBefore(new JwtFilter(jwtUtil, tokenService), UsernamePasswordAuthenticationFilter.class)
5152
.addFilterAt(
5253
new JwtLoginFilter(authenticationManager(authenticationConfiguration), tokenService),
5354
UsernamePasswordAuthenticationFilter.class)
54-
.addFilterAt(new JwtLogoutFilter(tokenService), LogoutFilter.class)
55+
.addFilterBefore(new JwtLogoutFilter(tokenService), LogoutFilter.class)
5556
.authorizeHttpRequests(
5657
authorizeRequests -> authorizeRequests
57-
.requestMatchers("/health", "/docs/**", "/token/reissue", "/v1/auth/**", "/v1/user/join")
58+
.requestMatchers("/health", "/docs/**", "/token/reissue", "/v1/auth/**", "/v1/user/join", "/logout")
5859
.permitAll()
5960
.anyRequest()
6061
.hasRole("USER"));

capturecat-core/src/main/java/com/capturecat/core/config/jwt/JwtFilter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,9 @@ private void rejectInvalidToken(HttpServletResponse response, ErrorType errorTyp
7272
objectMapper.writeValue(response.getWriter(), ApiResponse.error(errorType));
7373
}
7474

75+
@Override
76+
protected boolean shouldNotFilter(HttpServletRequest request) {
77+
String path = request.getServletPath();
78+
return "/logout".equals(path); // 로그아웃은 스킵
79+
}
7580
}

capturecat-core/src/main/java/com/capturecat/core/config/jwt/JwtLoginFilter.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import com.fasterxml.jackson.databind.ObjectMapper;
2020

2121
import lombok.RequiredArgsConstructor;
22+
import lombok.extern.slf4j.Slf4j;
2223

2324
import com.capturecat.core.api.user.dto.UserReqDto.LoginReqDto;
2425
import com.capturecat.core.domain.user.UserRole;
@@ -32,6 +33,7 @@
3233
* 소셜 로그인/회원가입이 아닌,
3334
* 일반 회원가입 후 /login 경로로, id, password로 로그인한 경우 (개발 용도)
3435
*/
36+
@Slf4j
3537
@RequiredArgsConstructor
3638
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
3739

@@ -64,6 +66,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR
6466

6567
//토큰 발급
6668
Map<TokenType, String> tokenMap = tokenIssueService.issue(username, UserRole.fromRoleString(role));
69+
log.info("[JwtLoginFilter.successfulAuthentication] 사용자 로그인({}), 토큰 발급", username);
6770

6871
//Header에 실어 응답
6972
response.setHeader(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + tokenMap.get(TokenType.ACCESS));

capturecat-core/src/main/java/com/capturecat/core/config/jwt/JwtLogoutFilter.java

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,72 +2,75 @@
22

33
import java.io.IOException;
44

5+
import jakarta.servlet.DispatcherType;
56
import jakarta.servlet.FilterChain;
6-
import jakarta.servlet.ServletException;
7-
import jakarta.servlet.ServletRequest;
8-
import jakarta.servlet.ServletResponse;
97
import jakarta.servlet.http.HttpServletRequest;
108
import jakarta.servlet.http.HttpServletResponse;
119

1210
import org.springframework.http.HttpHeaders;
1311
import org.springframework.http.HttpStatus;
1412
import org.springframework.http.MediaType;
1513
import org.springframework.util.StringUtils;
16-
import org.springframework.web.filter.GenericFilterBean;
14+
import org.springframework.web.filter.OncePerRequestFilter;
1715

1816
import com.fasterxml.jackson.databind.ObjectMapper;
1917

2018
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
2120

2221
import com.capturecat.core.service.auth.TokenService;
2322
import com.capturecat.core.support.error.CoreException;
2423
import com.capturecat.core.support.error.ErrorType;
2524
import com.capturecat.core.support.response.ApiResponse;
2625

26+
@Slf4j
2727
@RequiredArgsConstructor
28-
public class JwtLogoutFilter extends GenericFilterBean {
28+
public class JwtLogoutFilter extends OncePerRequestFilter {
2929

3030
private static final String LOGOUT_PATH = "/logout";
3131
private final TokenService tokenService;
3232
private final ObjectMapper objectMapper = new ObjectMapper();
3333

3434
@Override
35-
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
36-
throws IOException, ServletException {
37-
doFilter((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse, filterChain);
38-
}
39-
40-
/** 로그아웃
41-
* Refresh 토큰을 받아 DB에서 삭제 후 쿠키 null로 초기화하여 응답
42-
* (모든 기기에서 로그아웃 시 username 기반으로 모든 refresh 토큰 삭제)
43-
*/
44-
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
45-
throws IOException, ServletException {
46-
47-
// 로그아웃 요청일 때
48-
if (!request.getRequestURI().equals(LOGOUT_PATH)) {
49-
filterChain.doFilter(request, response);
50-
return;
35+
protected boolean shouldNotFilter(HttpServletRequest request) {
36+
// 1) ERROR/ASYNC 등 요청 아닌 디스패치 스킵
37+
if (request.getDispatcherType() != DispatcherType.REQUEST) {
38+
return true;
5139
}
40+
// 2) POST만 허용
41+
if (!"POST".equalsIgnoreCase(request.getMethod())) {
42+
return true;
43+
}
44+
// 3) /logout 경로만 통과
45+
return !LOGOUT_PATH.equals(request.getRequestURI());
46+
}
5247

53-
// 로그아웃
48+
@Override
49+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
50+
throws IOException {
5451
try {
5552
String accessHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
5653
String refreshHeader = request.getHeader(JwtUtil.REFRESH_TOKEN_HEADER);
5754
if (!StringUtils.hasText(accessHeader) || !StringUtils.hasText(refreshHeader)) {
58-
throw new CoreException(ErrorType.INVALID_AUTH_TOKEN);
55+
throw new CoreException(ErrorType.INVALID_LOGOUT_AUTH_TOKEN);
5956
}
57+
log.info("LogoutFilter hit: dispatcher={}, path={}", request.getDispatcherType(), request.getServletPath());
58+
6059
tokenService.revokeUserTokens(accessHeader, refreshHeader);
60+
61+
response.setStatus(HttpStatus.OK.value());
62+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
63+
objectMapper.writeValue(response.getWriter(), ApiResponse.success());
6164
} catch (CoreException e) {
6265
response.setStatus(HttpStatus.UNAUTHORIZED.value());
6366
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
6467
objectMapper.writeValue(response.getWriter(), ApiResponse.error(e.getErrorType()));
65-
return;
68+
} catch (Exception e) {
69+
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
70+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
71+
objectMapper.writeValue(response.getWriter(),
72+
ApiResponse.error(ErrorType.INTERNAL_SERVER_ERROR));
6673
}
67-
68-
// 성공 응답
69-
response.setStatus(HttpStatus.OK.value());
70-
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
71-
objectMapper.writeValue(response.getWriter(), ApiResponse.success());
74+
// 체인 종료 (추가 필터로 넘기지 않음)
7275
}
7376
}

capturecat-core/src/main/java/com/capturecat/core/config/jwt/JwtUtil.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import io.jsonwebtoken.MalformedJwtException;
1818
import io.jsonwebtoken.security.SignatureException;
1919

20+
import com.capturecat.core.support.error.CoreException;
21+
import com.capturecat.core.support.error.ErrorType;
22+
2023
@Component
2124
public class JwtUtil {
2225

@@ -77,10 +80,11 @@ public boolean isValid(String token) {
7780
return true;
7881
}
7982

80-
public String resolveToken(String header) {
81-
return header != null && header.startsWith(BEARER_PREFIX)
82-
? header.substring(BEARER_PREFIX.length()).trim()
83-
: null;
83+
public String resolveToken(String authHeader) {
84+
if (authHeader == null || !authHeader.startsWith(JwtUtil.BEARER_PREFIX)) {
85+
throw new CoreException(ErrorType.INVALID_ACCESS_TOKEN);
86+
}
87+
return authHeader.substring(BEARER_PREFIX.length()).trim();
8488
}
8589

8690
// JWT 파싱하여 만료일자(ms) 반환
@@ -89,7 +93,6 @@ public long getExpiration(String token) {
8993
return claims.getExpiration().getTime();
9094
}
9195

92-
9396
// username(subject) 추출
9497
public String getUsername(String token) {
9598
return extractClaims(token).getSubject();

capturecat-core/src/main/java/com/capturecat/core/service/auth/TokenService.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ private void saveRefreshToken(String username, String refreshToken) {
109109
* Refresh Token 삭제 및 Access Token 블랙리스트 등록
110110
*/
111111
public void revokeUserTokens(String accessTokenHeader, String refreshTokenHeader) {
112-
blacklistAccessToken(accessTokenHeader);
113-
deleteValidRefreshToken(refreshTokenHeader);
112+
try {
113+
blacklistAccessToken(accessTokenHeader);
114+
deleteValidRefreshToken(refreshTokenHeader);
115+
} catch (Exception e) {
116+
throw new CoreException(ErrorType.INTERNAL_SERVER_ERROR);
117+
}
114118
}
115119

116120
/**
@@ -145,6 +149,7 @@ public void blacklistAccessToken(String authHeader) {
145149
redisTemplate.opsForValue()
146150
.set(blacklistKey(accessToken), "blacklisted", remainMillis, TimeUnit.MILLISECONDS);
147151
}
152+
log.info("Blacklist Token: {}", accessToken);
148153
}
149154

150155
public boolean isBlacklisted(String accessToken) {

capturecat-core/src/main/java/com/capturecat/core/service/user/UserService.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ public String withdraw(LoginUser loginUser, String reason) {
113113
return resultMessage;
114114
}
115115

116-
@Transactional
117116
protected void deleteUserAndRelated(Long userId) {
118117
//1. 즐겨찾기 삭제
119118
bookmarkRepository.deleteByUserId(userId);

capturecat-core/src/main/java/com/capturecat/core/service/user/WithdrawLogService.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
public class WithdrawLogService {
1717
private final WithdrawLogRepository withdrawLogRepository;
1818

19-
// 전파 시 실패해도 록백하지 않도록
20-
@Transactional(propagation = Propagation.REQUIRES_NEW)
19+
@Transactional
2120
public void save(Long userId, String reason) {
2221
try {
2322
WithdrawLog log = WithdrawLog.builder()

capturecat-core/src/main/java/com/capturecat/core/support/error/ErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ public enum ErrorCode {
3636
UNLINK_SOCIAL_FAIL("소셜 로그인 연결 해제에 실패했습니다."),
3737
FETCH_SOCIAL_TOKEN_FAIL("소셜 서비스로부터 idToken 혹은 unlinkKey 획득에 실패했습니다."),
3838
SOCIAL_API_ERROR("소셜 서비스 API 호출 결과 실패를 응답받았습니다."),
39-
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다.");
39+
MISSING_PARAMETER("필수 파라미터 %s(이)가 누락되었습니다."),
40+
INTERNAL_SERVER_ERROR("서버에서 오류가 발생했습니다."),
41+
INVALID_LOGOUT_AUTH_TOKEN("ACCESS 토큰 또는 REFRESH 토큰이 유효하지 않습니다.");
4042

4143
private final String message;
4244

0 commit comments

Comments
 (0)