Skip to content

Commit 596fd98

Browse files
authored
Merge pull request #151 from prgrms-web-devcourse-final-project/develop
chore[deploy]: 토큰 적용
2 parents 5681c7e + 23348c0 commit 596fd98

File tree

11 files changed

+477
-233
lines changed

11 files changed

+477
-233
lines changed

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ public class MemberController {
3131
@ApiResponse(responseCode = "201", description = "회원가입 성공"),
3232
@ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)")
3333
})
34-
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody MemberSignupRequest request) {
34+
public ResponseEntity<MemberResponse> signup(@Valid @RequestBody MemberSignupRequest request, HttpServletResponse response) {
3535
log.info("회원가입 요청: email={}, name={}", request.getLoginId(), request.getName());
3636

37-
MemberResponse response = memberService.signup(request);
38-
log.info("회원가입 성공: memberId={}", response.getMemberId());
39-
return ResponseEntity.status(HttpStatus.CREATED).body(response);
37+
MemberResponse memberResponse = memberService.signup(request, response);
38+
log.info("회원가입 및 자동 로그인 성공: memberId={}", memberResponse.getMemberId());
39+
return ResponseEntity.status(HttpStatus.CREATED).body(memberResponse);
4040
}
4141

4242
@PostMapping("/login")

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class MemberService {
2626
private final EmailAuthService emailAuthService;
2727

2828
@Transactional
29-
public MemberResponse signup(MemberSignupRequest request) {
29+
public MemberResponse signup(MemberSignupRequest request, HttpServletResponse response) {
3030
validateDuplicateLoginId(request.getLoginId());
3131

3232
Member member = Member.builder()
@@ -39,6 +39,12 @@ public MemberResponse signup(MemberSignupRequest request) {
3939
.build();
4040

4141
Member savedMember = memberRepository.save(member);
42+
43+
// 회원가입 후 자동 로그인: JWT 토큰 생성 및 쿠키 설정
44+
String accessToken = tokenProvider.generateAccessToken(savedMember);
45+
String refreshToken = tokenProvider.generateRefreshToken(savedMember);
46+
cookieUtil.setTokenCookies(response, accessToken, refreshToken);
47+
4248
return MemberResponse.from(savedMember);
4349
}
4450

@@ -59,9 +65,9 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp
5965
}
6066

6167
public void logout(String loginId, HttpServletResponse response) {
62-
// 로그인 ID가 존재할 경우 Redis에서 리프레시 토큰 삭제
68+
// 로그인 ID가 존재할 경우 Redis에서 모든 토큰 삭제
6369
if (loginId != null && !loginId.trim().isEmpty()) {
64-
tokenProvider.deleteRefreshToken(loginId);
70+
tokenProvider.deleteAllTokens(loginId);
6571
}
6672

6773
// 인증 상태와 관계없이 클라이언트 쿠키 클리어
@@ -84,8 +90,8 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp
8490
Member member = memberRepository.findByLoginId(loginId)
8591
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다."));
8692

87-
// RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제
88-
tokenProvider.deleteRefreshToken(loginId);
93+
// RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제
94+
tokenProvider.deleteAllTokens(loginId);
8995

9096
// 새로운 액세스 토큰과 리프레시 토큰 생성
9197
String newAccessToken = tokenProvider.generateAccessToken(member);
@@ -163,8 +169,8 @@ public void resetPassword(String loginId, String newPassword, Boolean success) {
163169
// 인증 데이터 삭제 (비밀번호 재설정 완료 후)
164170
emailAuthService.clearAuthData(loginId);
165171

166-
// 기존 리프레시 토큰 삭제 (보안상 로그아웃 처리)
167-
tokenProvider.deleteRefreshToken(loginId);
172+
// 기존 모든 토큰 삭제 (보안상 로그아웃 처리)
173+
tokenProvider.deleteAllTokens(loginId);
168174
}
169175

170176
/**
Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
package com.ai.lawyer.global.config;
22

3-
import jakarta.annotation.PreDestroy;
43
import lombok.extern.slf4j.Slf4j;
54
import org.springframework.beans.factory.annotation.Value;
65
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
76
import org.springframework.context.annotation.Bean;
87
import org.springframework.context.annotation.Configuration;
9-
import org.springframework.context.event.ContextClosedEvent;
10-
import org.springframework.context.event.ContextRefreshedEvent;
11-
import org.springframework.context.event.EventListener;
128
import org.springframework.data.redis.connection.RedisConnectionFactory;
9+
import org.springframework.data.redis.connection.RedisPassword;
10+
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
1311
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
1412
import org.springframework.data.redis.core.RedisTemplate;
1513
import org.springframework.data.redis.serializer.StringRedisSerializer;
16-
import redis.embedded.RedisServer;
1714

1815
@Slf4j
1916
@Configuration
@@ -26,18 +23,27 @@ public class RedisConfig {
2623
@Value("${spring.data.redis.port:6379}")
2724
private int redisPort;
2825

29-
private RedisServer redisServer;
26+
@Value("${spring.data.redis.password:}")
27+
private String redisPassword;
3028

3129
@Bean
3230
public RedisConnectionFactory redisConnectionFactory() {
3331
log.info("=== RedisConnectionFactory 생성: host={}, port={} ===", redisHost, redisPort);
34-
return new LettuceConnectionFactory(redisHost, redisPort);
32+
33+
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
34+
35+
if (!redisPassword.isEmpty()) {
36+
config.setPassword(RedisPassword.of(redisPassword));
37+
log.info("=== Redis 패스워드 설정 완료 ===");
38+
}
39+
40+
return new LettuceConnectionFactory(config);
3541
}
3642

3743
@Bean
38-
public RedisTemplate<String, Object> redisTemplate() {
44+
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
3945
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
40-
redisTemplate.setConnectionFactory(redisConnectionFactory());
46+
redisTemplate.setConnectionFactory(redisConnectionFactory);
4147

4248
redisTemplate.setKeySerializer(new StringRedisSerializer());
4349
redisTemplate.setValueSerializer(new StringRedisSerializer());
@@ -47,30 +53,4 @@ public RedisTemplate<String, Object> redisTemplate() {
4753
log.info("=== RedisTemplate 설정 완료 (host={}, port={}) ===", redisHost, redisPort);
4854
return redisTemplate;
4955
}
50-
51-
@EventListener(ContextRefreshedEvent.class)
52-
public void startRedis() {
53-
try {
54-
redisServer = RedisServer.builder()
55-
.port(redisPort)
56-
.setting("maxmemory 128M")
57-
.build();
58-
59-
if (!redisServer.isActive()) {
60-
redisServer.start();
61-
log.info("=== Redis 서버가 포트 {}에서 시작되었습니다 ===", redisPort);
62-
}
63-
} catch (Exception e) {
64-
log.error("=== Redis 서버 시작 실패: {} ===", e.getMessage(), e);
65-
}
66-
}
67-
68-
@PreDestroy
69-
@EventListener(ContextClosedEvent.class)
70-
public void stopRedis() {
71-
if (redisServer != null && redisServer.isActive()) {
72-
redisServer.stop();
73-
log.info("=== Redis 서버가 중지되었습니다 ===");
74-
}
75-
}
7656
}

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

Lines changed: 84 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -32,28 +32,35 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable
3232
throws ServletException, IOException {
3333

3434
if (request != null && response != null) {
35-
// 1. Authorization 헤더에서 Bearer 토큰 추출 시도 (우선순위 1)
36-
String accessToken = extractTokenFromAuthorizationHeader(request);
37-
boolean fromHeader = accessToken != null;
38-
39-
// 2. Authorization 헤더에 없으면 쿠키에서 토큰 추출 (우선순위 2)
40-
if (accessToken == null) {
41-
accessToken = cookieUtil.getAccessTokenFromCookies(request);
42-
}
43-
44-
// JWT 액세스 토큰 검증 및 인증 처리
45-
if (accessToken != null) {
46-
TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken);
47-
48-
if (validationResult == TokenProvider.TokenValidationResult.VALID) {
49-
// 유효한 토큰인 경우 인증 처리
50-
setAuthentication(accessToken);
51-
} else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED && !fromHeader) {
52-
// 만료된 토큰이고 쿠키에서 왔을 경우에만 자동 갱신 시도
53-
// (Authorization 헤더 토큰은 클라이언트가 직접 관리해야 함)
54-
tryAutoRefreshToken(request, response, accessToken);
35+
try {
36+
// 1. 쿠키에서 액세스 토큰 확인
37+
String accessToken = cookieUtil.getAccessTokenFromCookies(request);
38+
39+
if (accessToken != null) {
40+
// 액세스 토큰이 있는 경우 검증
41+
TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken);
42+
43+
if (validationResult == TokenProvider.TokenValidationResult.VALID) {
44+
// 유효한 액세스 토큰 - 인증 처리
45+
setAuthentication(accessToken);
46+
log.debug("유효한 액세스 토큰으로 인증 완료");
47+
} else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED) {
48+
// 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도
49+
log.info("액세스 토큰 만료, 리프레시 토큰으로 갱신 시도");
50+
handleTokenRefresh(request, response, accessToken);
51+
} else {
52+
// 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인
53+
log.warn("유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도");
54+
handleTokenRefresh(request, response, null);
55+
}
56+
} else {
57+
// 4. 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인
58+
log.debug("액세스 토큰이 없음, 리프레시 토큰 확인");
59+
handleTokenRefresh(request, response, null);
5560
}
56-
// INVALID인 경우 아무 처리 하지 않음 (인증되지 않은 상태로 진행)
61+
} catch (Exception e) {
62+
log.error("JWT 인증 처리 중 오류 발생: {}", e.getMessage(), e);
63+
clearAuthenticationAndCookies(response);
5764
}
5865
}
5966

@@ -62,19 +69,6 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable
6269
}
6370
}
6471

65-
/**
66-
* Authorization 헤더에서 Bearer 토큰을 추출합니다.
67-
* @param request HTTP 요청
68-
* @return Bearer 토큰 값 또는 null
69-
*/
70-
private String extractTokenFromAuthorizationHeader(HttpServletRequest request) {
71-
String authHeader = request.getHeader("Authorization");
72-
if (authHeader != null && authHeader.startsWith("Bearer ")) {
73-
return authHeader.substring(7); // "Bearer " 제거
74-
}
75-
return null;
76-
}
77-
7872
/**
7973
* JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다.
8074
* @param token JWT 액세스 토큰
@@ -104,60 +98,88 @@ private void setAuthentication(String token) {
10498
}
10599

106100
/**
107-
* 만료된 액세스 토큰으로 자동 갱신을 시도합니다.
108-
* @param request HTTP 요청
109-
* @param response HTTP 응답
110-
* @param expiredAccessToken 만료된 액세스 토큰
101+
* 토큰 갱신을 처리합니다.
102+
* 2. 액세스토큰이 만료되었으면 리프레시토큰을확인한다
103+
* 3. 리프레시토큰이 레디스의 저장값과 동일하면 토큰 재발급을 진행한다
104+
* 6. 리프레시토큰을 확인하는절차에서 리프레시토큰이 없을 경우 쿠키에 있는 모든 정보를 제거하고 로그인을 해달라고 메시지를 반환한다
111105
*/
112-
private void tryAutoRefreshToken(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) {
106+
private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) {
113107
try {
114-
// 1. 만료된 토큰에서 loginId 추출
115-
String loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken);
116-
if (loginId == null) {
117-
log.warn("만료된 토큰에서 loginId 추출 실패");
108+
// 2. 리프레시 토큰 확인
109+
String refreshToken = cookieUtil.getRefreshTokenFromCookies(request);
110+
if (refreshToken == null) {
111+
// 6. 리프레시 토큰이 없을 경우 쿠키 클리어
112+
log.info("리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요");
113+
clearAuthenticationAndCookies(response);
118114
return;
119115
}
120116

121-
// 2. 쿠키에서 리프레시 토큰 추출
122-
String refreshToken = cookieUtil.getRefreshTokenFromCookies(request);
123-
if (refreshToken == null) {
124-
log.info("리프레시 토큰이 없어 자동 갱신 불가: {}", loginId);
117+
// loginId 추출 시도 (만료된 토큰이 있으면 그것에서, 없으면 리프레시 토큰으로 찾기)
118+
String loginId = null;
119+
if (expiredAccessToken != null) {
120+
loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken);
121+
}
122+
123+
// 만료된 토큰에서 추출 실패 시 리프레시 토큰으로 사용자 찾기
124+
if (loginId == null) {
125+
loginId = tokenProvider.findUsernameByRefreshToken(refreshToken);
126+
}
127+
128+
if (loginId == null) {
129+
log.warn("loginId 추출 실패 - 쿠키 클리어");
130+
clearAuthenticationAndCookies(response);
125131
return;
126132
}
127133

128-
// 3. 리프레시 토큰 유효성 검증
134+
// 3. 리프레시 토큰이 Redis의 저장값과 동일한지 검증
129135
if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) {
130-
log.info("유효하지 않은 리프레시 토큰으로 자동 갱신 불가: {}", loginId);
136+
log.info("유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}", loginId);
137+
clearAuthenticationAndCookies(response);
131138
return;
132139
}
133140

134-
// 4. 회원 정보 조회
141+
// 회원 정보 조회
135142
Member member = memberRepository.findByLoginId(loginId).orElse(null);
136143
if (member == null) {
137-
log.warn("존재하지 않는 회원으로 자동 갱신 불가: {}", loginId);
144+
log.warn("존재하지 않는 회원 - 쿠키 클리어: {}", loginId);
145+
clearAuthenticationAndCookies(response);
138146
return;
139147
}
140148

141-
// 5. RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제
142-
tokenProvider.deleteRefreshToken(loginId);
149+
// RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제
150+
tokenProvider.deleteAllTokens(loginId);
143151

144-
// 6. 새로운 액세스 토큰과 리프레시 토큰 생성
152+
// 새로운 액세스 토큰과 리프레시 토큰 생성
145153
String newAccessToken = tokenProvider.generateAccessToken(member);
146154
String newRefreshToken = tokenProvider.generateRefreshToken(member);
147155

148-
// 7. 새로운 토큰들을 쿠키에 설정
156+
// 새로운 토큰들을 쿠키에 설정
149157
cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken);
150158

151-
// 8. 새로운 액세스 토큰으로 인증 설정
159+
// 새로운 액세스 토큰으로 인증 설정
152160
setAuthentication(newAccessToken);
153161

154-
log.info("액세스 토큰 자동 갱신 성공: {}", loginId);
162+
log.info("토큰 자동 갱신 성공: {}", loginId);
155163

156164
} catch (Exception e) {
157-
log.warn("액세스 토큰 자동 갱신 실패: {}", e.getMessage());
165+
log.error("토큰 갱신 처리 실패: {}", e.getMessage(), e);
166+
clearAuthenticationAndCookies(response);
158167
}
159168
}
160169

170+
/**
171+
* 인증 정보와 쿠키를 모두 클리어합니다.
172+
*/
173+
private void clearAuthenticationAndCookies(HttpServletResponse response) {
174+
// Spring Security 인증 정보 클리어
175+
SecurityContextHolder.clearContext();
176+
177+
// 쿠키 클리어
178+
cookieUtil.clearTokenCookies(response);
179+
180+
log.debug("인증 정보 및 쿠키 클리어 완료");
181+
}
182+
161183
/**
162184
* JWT 인증이 필요하지 않은 경로들을 필터링에서 제외합니다.
163185
* @param request HTTP 요청
@@ -170,7 +192,10 @@ protected boolean shouldNotFilter(HttpServletRequest request) {
170192
path.equals("/api/auth/login") ||
171193
path.equals("/api/auth/refresh") ||
172194
path.startsWith("/api/public/") ||
195+
path.startsWith("/api/redis-test/") ||
173196
path.startsWith("/swagger-") ||
174-
path.startsWith("/v3/api-docs");
197+
path.startsWith("/v3/api-docs") ||
198+
path.equals("/actuator/health") ||
199+
path.startsWith("/h2-console");
175200
}
176201
}

0 commit comments

Comments
 (0)