44import java .time .LocalDateTime ;
55
66import org .apache .commons .lang3 .StringUtils ;
7+ import org .springframework .dao .DataIntegrityViolationException ;
78import org .springframework .stereotype .Service ;
89import org .springframework .transaction .annotation .Transactional ;
910
1011import com .back .api .auth .dto .JwtDto ;
1112import com .back .api .auth .dto .cache .RefreshTokenCache ;
13+ import com .back .api .auth .store .AuthStore ;
1214import com .back .domain .auth .entity .ActiveSession ;
1315import com .back .domain .auth .entity .RefreshToken ;
14- import com .back .domain .auth .repository .RefreshTokenRedisRepository ;
1516import com .back .domain .auth .repository .RefreshTokenRepository ;
1617import com .back .domain .user .entity .User ;
1718import com .back .domain .user .repository .UserRepository ;
1819import com .back .global .error .code .AuthErrorCode ;
1920import com .back .global .error .exception .ErrorException ;
20- import com .back .global .http .HttpRequestContext ;
21+ import com .back .global .http .RequestMetaProvider ;
2122import com .back .global .security .JwtClaims ;
2223import com .back .global .security .JwtProvider ;
2324import com .back .global .utils .TokenHash ;
2425
2526import lombok .RequiredArgsConstructor ;
27+ import lombok .extern .slf4j .Slf4j ;
2628
2729@ Service
2830@ RequiredArgsConstructor
31+ @ Slf4j
2932public class AuthTokenService {
3033
3134 private final JwtProvider jwtProvider ;
3235 private final RefreshTokenRepository tokenRepository ;
3336 private final UserRepository userRepository ;
34- private final HttpRequestContext requestContext ;
35- private final RefreshTokenRedisRepository refreshTokenRedisRepository ;
37+ private final RequestMetaProvider requestMetaProvider ;
3638 private final SessionGuard sessionGuard ;
39+ private final AuthStore authStore ;
3740
3841 @ Transactional
3942 public JwtDto issueTokens (User user , String sessionId , long tokenVersion ) {
4043 JwtDto dto = createJwtDto (user , sessionId , tokenVersion );
4144
42- saveRefreshToRedis (user .getId (), dto .refreshToken (), sessionId , tokenVersion );
43-
4445 saveRefreshMetaToDB (user , dto .refreshToken (), sessionId , tokenVersion );
4546
47+ saveRefreshToStoreBestEffort (user .getId (), dto .refreshToken (), sessionId , tokenVersion );
48+
4649 return dto ;
4750 }
4851
@@ -65,32 +68,91 @@ public JwtDto rotateTokenByRefreshToken(String refreshTokenStr) {
6568 long userId = claims .userId ();
6669 String sid = claims .sessionId ();
6770 long tokenVersion = claims .tokenVersion ();
68-
69- // 1. 세션을 읽고 현재 세션과 비교
70- ActiveSession active = sessionGuard .requireActiveSessionForUpdate (userId );
71-
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 ));
71+ String jti = claims .jti ();
7772
7873 String hash = TokenHash .sha256 (refreshTokenStr );
79- if (!hash .equals (cached .getRefreshTokenHash ())
80- || !sid .equals (cached .getSessionId ())
81- || tokenVersion != cached .getTokenVersion ()) {
74+
75+ // 캐시에 "현재 유효 refresh"가 들어있다면, 불일치 요청은 DB까지 가지 않고 차단
76+ if (tryEarlyRejectByCache (userId , sid , tokenVersion , hash )) {
77+ log .warn ("ROTATE_EARLY_DENY(cache): userId={} sid={} tokenVersion={} jti={}" ,
78+ userId , sid , tokenVersion , jti );
8279 throw new ErrorException (AuthErrorCode .ACCESS_OTHER_DEVICE );
8380 }
8481
82+ // 1. 세션을 읽고 현재 세션과 비교
83+ ActiveSession active = sessionGuard .requireActiveSessionForUpdate (userId );
84+ sessionGuard .assertMatches (active , sid , tokenVersion );
85+
8586 int updated = tokenRepository .revokeIfActive (hash );
8687 if (updated != 1 ) {
88+ log .warn ("Rotate denied by concurrent/invalid refresh userId={} sid={} tokenVersion={}" ,
89+ userId , sid , tokenVersion );
8790 throw new ErrorException (AuthErrorCode .ACCESS_OTHER_DEVICE );
8891 }
8992
9093 User user = userRepository .findById (userId )
9194 .orElseThrow (() -> new ErrorException (AuthErrorCode .UNAUTHORIZED ));
9295
93- return issueTokens (user , active .getSessionId (), active .getTokenVersion ());
96+ try {
97+ JwtDto dto = issueTokens (user , active .getSessionId (), active .getTokenVersion ());
98+ log .info ("ROTATE_OK: userId={} sid={} tokenVersion={} oldJti={}" , userId , sid , tokenVersion , jti );
99+ return dto ;
100+ } catch (DataIntegrityViolationException e ) {
101+ // partial unique index(유저당 active 1개) 위반 시그널
102+ log .error ("ROTATE_CONFLICT: unique active refresh violated userId={} sid={} tokenVersion={} jti={}" ,
103+ userId , sid , tokenVersion , jti , e );
104+ throw new ErrorException (AuthErrorCode .ACCESS_OTHER_DEVICE );
105+ }
106+ }
107+
108+ private boolean tryEarlyRejectByCache (long userId , String sid , long tokenVersion , String refreshHash ) {
109+ try {
110+ // 캐시가 아예 unavailable 이면(OPEN/timeout) -> DB로
111+ if (!authStore .isAvailable ()) {
112+ return false ;
113+ }
114+
115+ return authStore .findRefreshCache (userId )
116+ .map (cache ->
117+ !refreshHash .equals (cache .getRefreshTokenHash ())
118+ || !sid .equals (cache .getSessionId ())
119+ || tokenVersion != cache .getTokenVersion ())
120+ .orElse (false ); // cache miss 면 DB로
121+ } catch (Exception e ) {
122+ // Redis 장애는 rotate 자체를 막지 않고 DB로 진행 (가용성)
123+ log .warn ("Redis cache check failed -> DB fallback. userId={}, cause={}" , userId , e .toString ());
124+ return false ;
125+ }
126+ }
127+
128+ private void saveRefreshToStoreBestEffort (long userId , String refreshToken , String sid , long tokenVersion ) {
129+ if (!authStore .isAvailable ()) {
130+ log .warn ("RedisAuthStore unavailable: skip cache write userId={}" , userId );
131+ return ;
132+ }
133+
134+ JwtClaims claims = jwtProvider .payloadOrNull (refreshToken );
135+ if (claims == null ) {
136+ // 캐시에 잘못된 값 저장 방지
137+ log .warn ("Refresh token claims null: skip cache write userId={}" , userId );
138+ return ;
139+ }
140+
141+ long refreshValiditySeconds = jwtProvider .getRefreshTokenValiditySeconds ();
142+
143+ RefreshTokenCache cache = RefreshTokenCache .builder ()
144+ .refreshTokenHash (TokenHash .sha256 (refreshToken ))
145+ .sessionId (sid )
146+ .tokenVersion (tokenVersion )
147+ .jti (claims .jti ())
148+ .issuedAtEpochMs (System .currentTimeMillis ())
149+ .build ();
150+
151+ try {
152+ authStore .saveRefreshCache (userId , cache , Duration .ofSeconds (refreshValiditySeconds ));
153+ } catch (Exception e ) {
154+ log .warn ("Cache write failed (ignored). userId={}" , userId , e );
155+ }
94156 }
95157
96158 private JwtDto createJwtDto (User user , String sessionId , long tokenVersion ) {
@@ -119,25 +181,6 @@ private JwtDto createJwtDto(User user, String sessionId, long tokenVersion) {
119181 );
120182 }
121183
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-
141184 private void saveRefreshMetaToDB (User user , String refreshToken , String sid , long tokenVersion ) {
142185 JwtClaims claims = jwtProvider .payloadOrNull (refreshToken );
143186 String jti = (claims == null ) ? null : claims .jti ();
@@ -152,8 +195,8 @@ private void saveRefreshMetaToDB(User user, String refreshToken, String sid, lon
152195 .issuedAt (issuedAt )
153196 .expiresAt (expiresAt )
154197 .revoked (false )
155- .userAgent (requestContext . getUserAgent ())
156- .ipAddress (requestContext . getClientIp ())
198+ .userAgent (requestMetaProvider . userAgent ())
199+ .ipAddress (requestMetaProvider . clientIp ())
157200 .sessionId (sid )
158201 .tokenVersion (tokenVersion )
159202 .build ();
0 commit comments