Skip to content

Commit 8d8cb8b

Browse files
feat: refresh 토큰 저장 및 검증 테스트
1 parent 7492fd1 commit 8d8cb8b

File tree

5 files changed

+139
-43
lines changed

5 files changed

+139
-43
lines changed

infra/README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ infra/
1414

1515
### clean-ecs-cluster.yaml
1616

17-
**목적**: 테스트 환경용 ECS 클러스터 및 이중 서비스 구성
17+
**목적**: 테스트 환경용 ECS 클러스터 및 이중 서비스 구성 (ALB Access Logs 포함)
1818

1919
#### 주요 구성 요소
2020

@@ -39,23 +39,30 @@ infra/
3939

4040
##### 4. 로드 밸런서
4141
- **Application Load Balancer**: 인터넷 연결
42+
- **Access Logs**: S3 버킷 `the-first-take-ecs-log` 아래 `alb-logs/` 프리픽스로 저장
4243
- **Target Groups**:
4344
- Backend (포트 8000): `/api/*`, `/actuator/*` 경로
4445
- LLM (포트 6020): `/llm/*` 경로
4546
- Frontend (포트 80): 기본 경로
4647

4748
##### 5. ECS 서비스
4849
- **Backend Service**: Spring Boot 애플리케이션
49-
- CPU: 512, Memory: 1024MB
50-
- 이미지: `023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-backend:latest`
51-
- **LLM Service**: LLM 서버
5250
- CPU: 1024, Memory: 2048MB
53-
- 이미지: `023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-llm:latest`
51+
- 이미지: ECR 다이제스트 고정(예: `.../thefirsttake-backend@sha256:...`)
52+
- **LLM Service**: LLM 서버
53+
- CPU: 512, Memory: 1024MB
54+
- 이미지: ECR 다이제스트 고정(예: `.../thefirsttake-llm@sha256:...`)
5455

55-
##### 6. 환경변수 및 시크릿
56+
##### 6. ECR 리포지토리
57+
- `test-thefirsttake-backend` 자동 생성 (푸시 시 이미지 스캔 활성화)
58+
59+
##### 7. 환경변수 및 시크릿
5660
- **환경변수**: 데이터베이스, Redis, LLM 서버 URL 등
5761
- **시크릿**: 데이터베이스 패스워드, API 키 등 (Secrets Manager에서 관리)
5862

63+
참고:
64+
- LLM 관련 URL은 ALB 경유 경로로 주입(`/llm/api/...`)
65+
5966
## 🔧 배포 방법
6067

6168
### 1. 사전 요구사항
@@ -93,6 +100,11 @@ aws cloudformation describe-stacks --stack-name test-ecs-cluster
93100
aws cloudformation describe-stacks --stack-name test-ecs-cluster --query 'Stacks[0].Outputs'
94101
```
95102

103+
주요 출력값(Outputs):
104+
- `LoadBalancerURL`: `http://<ALB-DNS>` 형식의 URL
105+
- `LoadBalancerDNS`: ALB DNS 이름
106+
- `BackendServiceName`, `LLMServiceName`: 서비스명
107+
96108
## 🔍 모니터링
97109

98110
### CloudWatch 로그
@@ -105,6 +117,8 @@ aws cloudformation describe-stacks --stack-name test-ecs-cluster --query 'Stacks
105117
- **LLM**: `/llm/api/health`
106118
- **Frontend**: `/`
107119

120+
프런트엔드 타깃 그룹은 예시로 정적 타깃(IP) 1개가 설정되어 있습니다. 실제 환경에 맞게 대상 등록을 조정하세요.
121+
108122
## 📊 아키텍처
109123

110124
```

infra/clean-ecs-cluster.yaml

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
AWSTemplateFormatVersion: '2010-09-09'
2-
Description: 'Test ECS Cluster with dual services - Complete version'
2+
Description: 'Test ECS Cluster with dual services - Complete version with ALB Access Logs'
33

44
Parameters:
55
ClusterName:
@@ -157,7 +157,7 @@ Resources:
157157
ImageScanningConfiguration:
158158
ScanOnPush: true
159159

160-
# Load Balancer
160+
# Load Balancer with Access Logs
161161
TestApplicationLoadBalancer:
162162
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
163163
Properties:
@@ -167,6 +167,19 @@ Resources:
167167
Subnets: !Ref SubnetIds
168168
SecurityGroups:
169169
- !Ref TestALBSecurityGroup
170+
LoadBalancerAttributes:
171+
- Key: access_logs.s3.enabled
172+
Value: true
173+
- Key: access_logs.s3.bucket
174+
Value: the-first-take-ecs-log
175+
- Key: access_logs.s3.prefix
176+
Value: alb-logs
177+
- Key: deletion_protection.enabled
178+
Value: false
179+
- Key: idle_timeout.timeout_seconds
180+
Value: 60
181+
- Key: routing.http2.enabled
182+
Value: true
170183
Tags:
171184
- Key: Environment
172185
Value: test
@@ -197,12 +210,12 @@ Resources:
197210
TargetType: ip
198211
HealthCheckPath: /llm/api/health
199212
HealthCheckProtocol: HTTP
200-
HealthCheckIntervalSeconds: 300
201-
HealthCheckTimeoutSeconds: 29
202-
HealthyThresholdCount: 10
203-
UnhealthyThresholdCount: 10
213+
HealthCheckIntervalSeconds: 30
214+
HealthCheckTimeoutSeconds: 5
215+
HealthyThresholdCount: 2
216+
UnhealthyThresholdCount: 3
204217
Matcher:
205-
HttpCode: "200-499"
218+
HttpCode: "200"
206219

207220
TestFrontendTargetGroup:
208221
Type: AWS::ElasticLoadBalancingV2::TargetGroup
@@ -228,7 +241,7 @@ Resources:
228241
Properties:
229242
DefaultActions:
230243
- Type: forward
231-
TargetGroupArn: !Ref TestBackendTargetGroup
244+
TargetGroupArn: !Ref TestFrontendTargetGroup
232245
LoadBalancerArn: !Ref TestApplicationLoadBalancer
233246
Port: 80
234247
Protocol: HTTP
@@ -281,13 +294,13 @@ Resources:
281294
NetworkMode: awsvpc
282295
RequiresCompatibilities:
283296
- FARGATE
284-
Cpu: 512
285-
Memory: 1024
297+
Cpu: 1024
298+
Memory: 2048
286299
ExecutionRoleArn: !Ref TestECSTaskExecutionRole
287300
TaskRoleArn: !Ref TestECSTaskRole
288301
ContainerDefinitions:
289302
- Name: backend
290-
Image: 023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-backend:latest
303+
Image: 023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-backend@sha256:7a842089c04219288bea0fdf7b8ef881868317e68cad45dbda598c1d5e2cdc67
291304
PortMappings:
292305
- ContainerPort: 8000
293306
Protocol: tcp
@@ -355,13 +368,13 @@ Resources:
355368
NetworkMode: awsvpc
356369
RequiresCompatibilities:
357370
- FARGATE
358-
Cpu: 1024
359-
Memory: 2048
371+
Cpu: 512
372+
Memory: 1024
360373
ExecutionRoleArn: !Ref TestECSTaskExecutionRole
361374
TaskRoleArn: !Ref TestECSTaskRole
362375
ContainerDefinitions:
363376
- Name: llm-server
364-
Image: 023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-llm:latest
377+
Image: 023182678225.dkr.ecr.ap-northeast-2.amazonaws.com/thefirsttake-llm@sha256:717a228c58e660908aab7c57c1a0cb49d7d96cc07bba0be4fd90a4c21c17d2aa
365378
PortMappings:
366379
- ContainerPort: 6020
367380
Protocol: tcp
@@ -457,6 +470,7 @@ Resources:
457470
- Key: Service
458471
Value: llm
459472
DependsOn:
473+
- TestALBListener
460474
- TestLLMListenerRule
461475

462476
Outputs:

thefirsttake/src/main/java/com/thefirsttake/app/auth/controller/AuthController.java

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.thefirsttake.app.auth.service.JwtService;
66
import com.thefirsttake.app.auth.service.KakaoAuthService;
77
import com.thefirsttake.app.common.response.CommonResponse;
8+
import com.thefirsttake.app.auth.service.RefreshTokenService;
89
import com.thefirsttake.app.common.user.entity.UserEntity;
910
import com.thefirsttake.app.common.user.repository.UserEntityRepository;
1011
import io.micrometer.core.instrument.Counter;
@@ -30,6 +31,7 @@
3031
import java.net.URI;
3132
import java.net.URLEncoder;
3233
import java.nio.charset.StandardCharsets;
34+
import java.time.Duration;
3335

3436
@RestController
3537
@RequestMapping("/api/auth")
@@ -40,6 +42,7 @@ public class AuthController {
4042

4143
private final KakaoAuthService kakaoAuthService;
4244
private final JwtService jwtService;
45+
private final RefreshTokenService refreshTokenService;
4346
private final UserEntityRepository userEntityRepository;
4447
private final Counter kakaoLoginSuccessCounter;
4548
private final Counter kakaoLoginFailureCounter;
@@ -157,6 +160,8 @@ public ResponseEntity<Void> kakaoCallback(
157160

158161
response.addCookie(accessTokenCookie);
159162
// 요구사항: 일단 refresh 토큰은 쿠키에 저장하지 않음
163+
// 리프레시 토큰은 Redis에 저장 (TTL 7일)
164+
refreshTokenService.saveRefreshToken(String.valueOf(userEntity.getId()), jwtRefreshToken, Duration.ofDays(7));
160165

161166
log.info("JWT 토큰 설정 완료. 액세스 토큰 길이: {}, 리프레시 토큰 생성됨(쿠키 미저장)",
162167
jwtAccessToken.length());
@@ -269,24 +274,29 @@ async function logout() {
269274
)
270275
})
271276
@PostMapping("/logout")
272-
public ResponseEntity<CommonResponse> logout(HttpServletResponse response) {
277+
public ResponseEntity<CommonResponse> logout(HttpServletRequest request, HttpServletResponse response) {
273278
try {
279+
// 쿠키의 액세스 토큰에서 사용자 ID 추출 (만료 허용)
280+
String accessToken = extractJwtFromCookies(request.getCookies());
281+
if (accessToken != null) {
282+
String userId = null;
283+
try {
284+
userId = jwtService.getUserIdFromToken(accessToken);
285+
} catch (Exception e) {
286+
userId = jwtService.getUserIdFromExpiredToken(accessToken);
287+
}
288+
if (userId != null) {
289+
// Redis의 리프레시 토큰 삭제
290+
refreshTokenService.deleteRefreshToken(userId);
291+
}
292+
}
274293
// 액세스 토큰 쿠키 삭제
275294
Cookie accessTokenCookie = new Cookie("access_token", null);
276295
accessTokenCookie.setMaxAge(0);
277296
accessTokenCookie.setPath("/");
278297
accessTokenCookie.setHttpOnly(true);
279298
accessTokenCookie.setSecure(true);
280299
response.addCookie(accessTokenCookie);
281-
282-
// 리프레시 토큰 쿠키 삭제
283-
Cookie refreshTokenCookie = new Cookie("refresh_token", null);
284-
refreshTokenCookie.setMaxAge(0);
285-
refreshTokenCookie.setPath("/");
286-
refreshTokenCookie.setHttpOnly(true);
287-
refreshTokenCookie.setSecure(true);
288-
response.addCookie(refreshTokenCookie);
289-
290300
// 로그아웃 메트릭 증가
291301
logoutCounter.increment();
292302

@@ -434,30 +444,47 @@ async function refreshToken() {
434444
@PostMapping("/refresh")
435445
public ResponseEntity<CommonResponse> refreshToken(HttpServletRequest request, HttpServletResponse response) {
436446
try {
437-
// 리프레시 토큰 추출 (현재 쿠키 미사용 정책)
438-
String refreshToken = null; // 쿠키에서 추출하지 않음
439-
447+
// 1) 액세스 토큰(만료 가능)에서 사용자 ID 추출
448+
String accessToken = extractJwtFromCookies(request.getCookies());
449+
if (accessToken == null) {
450+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
451+
.body(CommonResponse.fail("액세스 토큰이 없습니다"));
452+
}
453+
String userId;
454+
try {
455+
userId = jwtService.getUserIdFromToken(accessToken);
456+
} catch (Exception e) {
457+
userId = jwtService.getUserIdFromExpiredToken(accessToken);
458+
}
459+
if (userId == null) {
460+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
461+
.body(CommonResponse.fail("사용자 식별 실패"));
462+
}
463+
464+
// 2) Redis에서 리프레시 토큰 조회 및 검증
465+
String refreshToken = refreshTokenService.getRefreshToken(userId);
440466
if (refreshToken == null || !jwtService.validateToken(refreshToken) || !jwtService.isRefreshToken(refreshToken)) {
441467
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
442-
.body(CommonResponse.fail("유효하지 않은 리프레시 토큰"));
468+
.body(CommonResponse.fail("리프레시 토큰이 유효하지 않습니다"));
443469
}
444-
445-
// 사용자 ID 추출
446-
String userId = jwtService.getUserIdFromToken(refreshToken);
447-
448-
// 새로운 토큰 생성
470+
String refreshUserId = jwtService.getUserIdFromToken(refreshToken);
471+
if (!userId.equals(refreshUserId)) {
472+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
473+
.body(CommonResponse.fail("토큰 소유자 불일치"));
474+
}
475+
476+
// 3) 새로운 액세스 토큰 발급 (토큰 회전은 추후)
449477
String newAccessToken = jwtService.generateAccessToken(userId);
450-
String newRefreshToken = jwtService.generateRefreshToken(userId);
451-
452-
// 새로운 쿠키 설정 (refresh 토큰은 쿠키에 저장하지 않음)
478+
479+
// 4) 액세스 토큰 쿠키 설정
453480
Cookie accessTokenCookie = new Cookie("access_token", newAccessToken);
454481
accessTokenCookie.setHttpOnly(true);
455482
accessTokenCookie.setSecure(true);
456483
accessTokenCookie.setPath("/");
457484
accessTokenCookie.setMaxAge(15 * 60); // 15분
458485

459486
response.addCookie(accessTokenCookie);
460-
// refresh 토큰은 서버 저장(추후 Redis) 예정, 쿠키 미저장
487+
// refresh 토큰은 Redis 보관, 쿠키 미저장
461488

462489
log.info("토큰 갱신 성공. 사용자 ID: {}", userId);
463490
return ResponseEntity.ok(CommonResponse.success("토큰 갱신 성공"));

thefirsttake/src/main/java/com/thefirsttake/app/auth/service/JwtService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,12 @@ public boolean isAccessToken(String token) {
106106
public boolean isRefreshToken(String token) {
107107
return "refresh".equals(getTokenType(token));
108108
}
109+
110+
public String getUserIdFromExpiredToken(String token) {
111+
try {
112+
return getUserIdFromToken(token);
113+
} catch (ExpiredJwtException e) {
114+
return e.getClaims().getSubject();
115+
}
116+
}
109117
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.thefirsttake.app.auth.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.StringRedisTemplate;
5+
import org.springframework.stereotype.Service;
6+
7+
import java.time.Duration;
8+
9+
@Service
10+
@RequiredArgsConstructor
11+
public class RefreshTokenService {
12+
private final StringRedisTemplate stringRedisTemplate;
13+
14+
private String buildKey(String userId) {
15+
// Key format: {user_id}:refresh_token (per requested convention)
16+
return userId + ":refresh_token";
17+
}
18+
19+
public void saveRefreshToken(String userId, String refreshToken, Duration ttl) {
20+
String key = buildKey(userId);
21+
stringRedisTemplate.opsForValue().set(key, refreshToken, ttl);
22+
}
23+
24+
public String getRefreshToken(String userId) {
25+
String key = buildKey(userId);
26+
return stringRedisTemplate.opsForValue().get(key);
27+
}
28+
29+
public void deleteRefreshToken(String userId) {
30+
String key = buildKey(userId);
31+
stringRedisTemplate.delete(key);
32+
}
33+
}

0 commit comments

Comments
 (0)