Skip to content

Commit 34cab7e

Browse files
authored
[refactor] refresh token을 Redis에 저장하고 토큰값 비교
1 parent 771c0da commit 34cab7e

33 files changed

Lines changed: 948 additions & 310 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.back.api.auth.dto.cache;
2+
3+
import java.io.Serializable;
4+
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
@Getter
11+
@Builder
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
public class RefreshTokenCache implements Serializable {
15+
private String refreshTokenHash;
16+
private String sessionId;
17+
private long tokenVersion;
18+
private String jti; // JWT id
19+
private long issuedAtEpochMs;
20+
}

backend/src/main/java/com/back/api/auth/service/AuthService.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.back.domain.auth.entity.ActiveSession;
1717
import com.back.domain.auth.entity.RefreshToken;
1818
import com.back.domain.auth.repository.ActiveSessionRepository;
19+
import com.back.domain.auth.repository.RefreshTokenRedisRepository;
1920
import com.back.domain.auth.repository.RefreshTokenRepository;
2021
import com.back.domain.user.entity.User;
2122
import com.back.domain.user.entity.UserActiveStatus;
@@ -24,6 +25,7 @@
2425
import com.back.global.error.code.AuthErrorCode;
2526
import com.back.global.error.exception.ErrorException;
2627
import com.back.global.http.HttpRequestContext;
28+
import com.back.global.utils.TokenHash;
2729

2830
import lombok.RequiredArgsConstructor;
2931

@@ -37,6 +39,7 @@ public class AuthService {
3739
private final HttpRequestContext requestContext;
3840
private final RefreshTokenRepository refreshTokenRepository;
3941
private final ActiveSessionRepository activeSessionRepository;
42+
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
4043

4144
@Transactional
4245
public AuthResponse signup(SignupRequest request) {
@@ -63,7 +66,7 @@ public AuthResponse signup(SignupRequest request) {
6366

6467
User savedUser = userRepository.save(user);
6568

66-
JwtDto tokens = setSessionAndCookie(savedUser);
69+
JwtDto tokens = loginAsSingleDevice(savedUser);
6770

6871
return buildAuthResponse(savedUser, tokens);
6972
}
@@ -77,7 +80,7 @@ public AuthResponse login(LoginRequest request) {
7780
throw new ErrorException(AuthErrorCode.LOGIN_FAILED);
7881
}
7982

80-
JwtDto tokens = setSessionAndCookie(user);
83+
JwtDto tokens = loginAsSingleDevice(user);
8184

8285
return buildAuthResponse(user, tokens);
8386
}
@@ -89,12 +92,18 @@ public void logout() {
8992
throw new ErrorException(AuthErrorCode.REFRESH_TOKEN_REQUIRED);
9093
}
9194

95+
long userId = requestContext.getUserId();
96+
97+
String refreshHash = TokenHash.sha256(refreshTokenStr);
98+
9299
RefreshToken refreshToken = refreshTokenRepository
93-
.findByTokenAndUserIdAndRevokedFalse(refreshTokenStr, requestContext.getUserId())
94-
.orElseThrow(() -> new ErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND));
100+
.findByTokenAndUserIdAndRevokedFalse(refreshHash, requestContext.getUserId())
101+
.orElseThrow(() -> new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE));
95102

96103
refreshToken.revoke();
97104

105+
refreshTokenRedisRepository.delete(userId);
106+
98107
requestContext.deleteAuthCookies();
99108
}
100109

@@ -107,15 +116,18 @@ public void verifyPassword(String rawPassword) {
107116
}
108117
}
109118

110-
private JwtDto setSessionAndCookie(User user) {
119+
private JwtDto loginAsSingleDevice(User user) {
111120
ActiveSession session = activeSessionRepository.findByUserIdForUpdate(user.getId())
112121
.orElseGet(() -> activeSessionRepository.save(ActiveSession.create(user)));
113122

114123
session.rotate();
115124

125+
ActiveSession saved = activeSessionRepository.saveAndFlush(session);
126+
116127
refreshTokenRepository.revokeAllActiveByUserId(user.getId());
128+
refreshTokenRedisRepository.delete(user.getId());
117129

118-
JwtDto tokens = authTokenService.issueTokens(user, session.getSessionId(), session.getTokenVersion());
130+
JwtDto tokens = authTokenService.issueTokens(user, saved.getSessionId(), saved.getTokenVersion());
119131

120132
requestContext.setAccessTokenCookie(tokens.accessToken());
121133
requestContext.setRefreshTokenCookie(tokens.refreshToken());
Lines changed: 93 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
package com.back.api.auth.service;
22

3+
import java.time.Duration;
34
import java.time.LocalDateTime;
45

56
import org.apache.commons.lang3.StringUtils;
67
import org.springframework.stereotype.Service;
78
import org.springframework.transaction.annotation.Transactional;
89

910
import com.back.api.auth.dto.JwtDto;
11+
import com.back.api.auth.dto.cache.RefreshTokenCache;
1012
import com.back.domain.auth.entity.ActiveSession;
1113
import com.back.domain.auth.entity.RefreshToken;
12-
import com.back.domain.auth.repository.ActiveSessionRepository;
14+
import com.back.domain.auth.repository.RefreshTokenRedisRepository;
1315
import com.back.domain.auth.repository.RefreshTokenRepository;
1416
import com.back.domain.user.entity.User;
1517
import com.back.domain.user.repository.UserRepository;
@@ -18,6 +20,7 @@
1820
import com.back.global.http.HttpRequestContext;
1921
import com.back.global.security.JwtClaims;
2022
import com.back.global.security.JwtProvider;
23+
import com.back.global.utils.TokenHash;
2124

2225
import lombok.RequiredArgsConstructor;
2326

@@ -27,53 +30,20 @@ public class AuthTokenService {
2730

2831
private final JwtProvider jwtProvider;
2932
private final RefreshTokenRepository tokenRepository;
30-
private final ActiveSessionRepository activeSessionRepository;
3133
private final UserRepository userRepository;
3234
private final HttpRequestContext requestContext;
35+
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
36+
private final SessionGuard sessionGuard;
3337

3438
@Transactional
3539
public JwtDto issueTokens(User user, String sessionId, long tokenVersion) {
36-
String accessToken = jwtProvider.generateAccessToken(user, sessionId, tokenVersion);
37-
String refreshTokenStr = jwtProvider.generateRefreshToken(user, sessionId, tokenVersion);
40+
JwtDto dto = createJwtDto(user, sessionId, tokenVersion);
3841

39-
// === seconds 기준 ===
40-
long accessValiditySeconds = jwtProvider.getAccessTokenValiditySeconds();
41-
long refreshValiditySeconds = jwtProvider.getRefreshTokenValiditySeconds();
42+
saveRefreshToRedis(user.getId(), dto.refreshToken(), sessionId, tokenVersion);
4243

43-
// === 현재 시각 ===
44-
long nowEpochMillis = System.currentTimeMillis();
45-
LocalDateTime issuedAt = LocalDateTime.now();
46-
47-
// === DB 저장용 만료 시각 (LocalDateTime) ===
48-
LocalDateTime expiresAt = issuedAt.plusSeconds(refreshValiditySeconds);
44+
saveRefreshMetaToDB(user, dto.refreshToken(), sessionId, tokenVersion);
4945

50-
RefreshToken refreshToken = RefreshToken.builder()
51-
.user(user)
52-
.token(refreshTokenStr)
53-
.issuedAt(issuedAt)
54-
.expiresAt(expiresAt)
55-
.revoked(false)
56-
.userAgent(requestContext.getUserAgent())
57-
.ipAddress(requestContext.getClientIp())
58-
.sessionId(sessionId)
59-
.tokenVersion(tokenVersion)
60-
.build();
61-
62-
tokenRepository.save(refreshToken);
63-
64-
// === API 응답용 epoch millis ===
65-
long accessExpiresAtMillis = nowEpochMillis + (accessValiditySeconds * 1000);
66-
long refreshExpiresAtMillis = nowEpochMillis + (refreshValiditySeconds * 1000);
67-
68-
return new JwtDto(
69-
JwtDto.BEARER,
70-
accessToken,
71-
accessExpiresAtMillis,
72-
accessValiditySeconds * 1000,
73-
refreshTokenStr,
74-
refreshExpiresAtMillis,
75-
refreshValiditySeconds * 1000
76-
);
46+
return dto;
7747
}
7848

7949
@Transactional
@@ -96,21 +66,98 @@ public JwtDto rotateTokenByRefreshToken(String refreshTokenStr) {
9666
String sid = claims.sessionId();
9767
long tokenVersion = claims.tokenVersion();
9868

99-
ActiveSession active = activeSessionRepository.findByUserIdForUpdate(userId)
100-
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
69+
// 1. 세션을 읽고 현재 세션과 비교
70+
ActiveSession active = sessionGuard.requireActiveSessionForUpdate(userId);
10171

102-
if (!active.getSessionId().equals(sid) || active.getTokenVersion() != tokenVersion) {
72+
sessionGuard.assertMatches(active, sid, tokenVersion);
73+
74+
// 2. Redis 검증: 현재 유효 refresh 인지 확인 (해시 비교)
75+
RefreshTokenCache cached = refreshTokenRedisRepository.find(userId)
76+
.orElseThrow(() -> new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE));
77+
78+
String hash = TokenHash.sha256(refreshTokenStr);
79+
if (!hash.equals(cached.getRefreshTokenHash())
80+
|| !sid.equals(cached.getSessionId())
81+
|| tokenVersion != cached.getTokenVersion()) {
10382
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
10483
}
10584

106-
int revoked = tokenRepository.revokeIfActive(refreshTokenStr);
107-
if (revoked != 1) {
108-
throw new ErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND);
85+
int updated = tokenRepository.revokeIfActive(hash);
86+
if (updated != 1) {
87+
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
10988
}
11089

11190
User user = userRepository.findById(userId)
11291
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
11392

11493
return issueTokens(user, active.getSessionId(), active.getTokenVersion());
11594
}
95+
96+
private JwtDto createJwtDto(User user, String sessionId, long tokenVersion) {
97+
String accessToken = jwtProvider.generateAccessToken(user, sessionId, tokenVersion);
98+
String refreshToken = jwtProvider.generateRefreshToken(user, sessionId, tokenVersion);
99+
100+
// === seconds 기준 ===
101+
long accessValiditySeconds = jwtProvider.getAccessTokenValiditySeconds();
102+
long refreshValiditySeconds = jwtProvider.getRefreshTokenValiditySeconds();
103+
104+
// === 현재 시각 ===
105+
long nowEpochMillis = System.currentTimeMillis();
106+
107+
// === API 응답용 epoch millis ===
108+
long accessExpiresAtMillis = nowEpochMillis + (accessValiditySeconds * 1000);
109+
long refreshExpiresAtMillis = nowEpochMillis + (refreshValiditySeconds * 1000);
110+
111+
return new JwtDto(
112+
JwtDto.BEARER,
113+
accessToken,
114+
accessExpiresAtMillis,
115+
accessValiditySeconds * 1000,
116+
refreshToken,
117+
refreshExpiresAtMillis,
118+
refreshValiditySeconds * 1000
119+
);
120+
}
121+
122+
private void saveRefreshToRedis(long userId, String refreshToken, String sid, long tokenVersion) {
123+
JwtClaims claims = jwtProvider.payloadOrNull(refreshToken);
124+
if (claims == null) {
125+
throw new ErrorException(AuthErrorCode.INVALID_TOKEN);
126+
}
127+
128+
long refreshValiditySeconds = jwtProvider.getRefreshTokenValiditySeconds();
129+
130+
RefreshTokenCache cache = RefreshTokenCache.builder()
131+
.refreshTokenHash(TokenHash.sha256(refreshToken))
132+
.sessionId(sid)
133+
.tokenVersion(tokenVersion)
134+
.jti(claims.jti())
135+
.issuedAtEpochMs(System.currentTimeMillis())
136+
.build();
137+
138+
refreshTokenRedisRepository.save(userId, cache, Duration.ofSeconds(refreshValiditySeconds));
139+
}
140+
141+
private void saveRefreshMetaToDB(User user, String refreshToken, String sid, long tokenVersion) {
142+
JwtClaims claims = jwtProvider.payloadOrNull(refreshToken);
143+
String jti = (claims == null) ? null : claims.jti();
144+
145+
LocalDateTime issuedAt = LocalDateTime.now();
146+
LocalDateTime expiresAt = issuedAt.plusSeconds(jwtProvider.getRefreshTokenValiditySeconds());
147+
148+
RefreshToken meta = RefreshToken.builder()
149+
.user(user)
150+
.token(TokenHash.sha256(refreshToken))
151+
.jti(jti)
152+
.issuedAt(issuedAt)
153+
.expiresAt(expiresAt)
154+
.revoked(false)
155+
.userAgent(requestContext.getUserAgent())
156+
.ipAddress(requestContext.getClientIp())
157+
.sessionId(sid)
158+
.tokenVersion(tokenVersion)
159+
.build();
160+
161+
tokenRepository.save(meta);
162+
}
116163
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.back.api.auth.service;
2+
3+
import org.springframework.stereotype.Component;
4+
import org.springframework.transaction.annotation.Transactional;
5+
6+
import com.back.domain.auth.entity.ActiveSession;
7+
import com.back.domain.auth.repository.ActiveSessionRepository;
8+
import com.back.global.error.code.AuthErrorCode;
9+
import com.back.global.error.exception.ErrorException;
10+
11+
import lombok.RequiredArgsConstructor;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class SessionGuard {
16+
17+
private final ActiveSessionRepository activeSessionRepository;
18+
19+
@Transactional(readOnly = true)
20+
public ActiveSession requireActiveSession(long userId) {
21+
return activeSessionRepository.findByUserId(userId)
22+
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
23+
}
24+
25+
@Transactional
26+
public ActiveSession requireActiveSessionForUpdate(long userId) {
27+
return activeSessionRepository.findByUserIdForUpdate(userId)
28+
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
29+
}
30+
31+
public void assertMatches(ActiveSession active, String sid, long tokenVersion) {
32+
if (!active.getSessionId().equals(sid) || active.getTokenVersion() != tokenVersion) {
33+
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
34+
}
35+
}
36+
}

backend/src/main/java/com/back/api/event/controller/AdminEventController.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import java.util.List;
44

5+
import org.springframework.security.access.prepost.PreAuthorize;
56
import org.springframework.web.bind.annotation.DeleteMapping;
67
import org.springframework.web.bind.annotation.GetMapping;
78
import org.springframework.web.bind.annotation.PathVariable;
@@ -30,6 +31,7 @@ public class AdminEventController implements AdminEventApi {
3031

3132
@Override
3233
@PostMapping
34+
@PreAuthorize("hasRole('ADMIN')")
3335
public ApiResponse<EventResponse> createEvent(
3436
@Valid @RequestBody EventCreateRequest request) {
3537
EventResponse response = adminEventService.createEvent(request);
@@ -38,6 +40,7 @@ public ApiResponse<EventResponse> createEvent(
3840

3941
@Override
4042
@PutMapping("/{eventId}")
43+
@PreAuthorize("hasRole('ADMIN')")
4144
public ApiResponse<EventResponse> updateEvent(
4245
@PathVariable Long eventId,
4346
@Valid @RequestBody EventUpdateRequest request) {
@@ -47,6 +50,7 @@ public ApiResponse<EventResponse> updateEvent(
4750

4851
@Override
4952
@DeleteMapping("/{eventId}")
53+
@PreAuthorize("hasRole('ADMIN')")
5054
public ApiResponse<Void> deleteEvent(
5155
@PathVariable Long eventId) {
5256
adminEventService.deleteEvent(eventId);
@@ -55,6 +59,7 @@ public ApiResponse<Void> deleteEvent(
5559

5660
@Override
5761
@GetMapping("/dashboard")
62+
@PreAuthorize("hasRole('ADMIN')")
5863
public ApiResponse<List<AdminEventDashboardResponse>> getAllEventsDashboard() {
5964
List<AdminEventDashboardResponse> responses = adminEventService.getAllEventsDashboard();
6065
return ApiResponse.ok("이벤트 현황 조회 성공", responses);

backend/src/main/java/com/back/api/event/controller/EventController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import org.springframework.data.domain.Page;
44
import org.springframework.data.domain.Pageable;
55
import org.springframework.data.web.PageableDefault;
6+
import org.springframework.security.access.prepost.PreAuthorize;
67
import org.springframework.web.bind.annotation.GetMapping;
78
import org.springframework.web.bind.annotation.PathVariable;
89
import org.springframework.web.bind.annotation.RequestMapping;
@@ -27,6 +28,7 @@ public class EventController implements EventApi {
2728

2829
@Override
2930
@GetMapping("/{eventId}")
31+
@PreAuthorize("hasRole('NORMAL')")
3032
public ApiResponse<EventResponse> getEvent(
3133
@PathVariable Long eventId) {
3234
EventResponse response = eventService.getEvent(eventId);
@@ -35,6 +37,7 @@ public ApiResponse<EventResponse> getEvent(
3537

3638
@Override
3739
@GetMapping
40+
@PreAuthorize("hasRole('NORMAL')")
3841
public ApiResponse<Page<EventListResponse>> getEvents(
3942
@RequestParam(required = false) EventStatus status,
4043
@RequestParam(required = false) EventCategory category,

0 commit comments

Comments
 (0)