From 0fac055799951e653f0a988383653f2e00e02010 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:16:16 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20BCryptPasswordEncoder=EB=A5=BC?= =?UTF-8?q?=20DelegatingPasswordEncoder=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프리픽스 기반 알고리즘 식별을 도입하여 향후 Argon2id 등으로 점진적 마이그레이션할 수 있는 경로를 확보한다. --- src/main/java/org/sopt/global/config/SecurityConfig.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/sopt/global/config/SecurityConfig.java b/src/main/java/org/sopt/global/config/SecurityConfig.java index 9f8f1dd..0fb0c17 100644 --- a/src/main/java/org/sopt/global/config/SecurityConfig.java +++ b/src/main/java/org/sopt/global/config/SecurityConfig.java @@ -11,7 +11,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -58,8 +58,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .build(); } + /** + * {@code {bcrypt}} 프리픽스 기반의 DelegatingPasswordEncoder를 제공한다. + * 새로 저장되는 비밀번호는 {@code {bcrypt}} 프리픽스가 붙어 알고리즘을 식별하며, + * 향후 Argon2id 등으로 점진적 마이그레이션이 가능하다. + */ @Bean public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } } From 891786fbcde9fb9c1c091e03493930ea22dad53a Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:16:35 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20Access=20Token=20=EB=B8=94?= =?UTF-8?q?=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20Caffeine=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RDB 블랙리스트 엔티티와 JPA 레포지토리를 제거하고, per-entry TTL을 토큰 잔여 유효 시간에 정렬하는 Caffeine 캐시로 대체한다. 매 요청 DB 조회를 인메모리 조회로 절감하고, 만료된 항목은 자동 제거된다. 사용자 역할 조회도 Caffeine 캐시(5분 TTL)를 적용하여 DB 부하를 줄인다. --- build.gradle | 3 + .../domain/model/AccessTokenBlacklist.java | 49 --------------- .../AccessTokenBlacklistRepository.java | 16 ++--- .../AccessTokenBlacklistJpaRepository.java | 18 ------ .../AccessTokenBlacklistRepositoryImpl.java | 38 ------------ ...affeineAccessTokenBlacklistRepository.java | 52 ++++++++++++++++ .../service/UserCommandService.java | 12 ++-- .../JwtAuthenticationFilter.java | 60 ++++++++++++++++--- 8 files changed, 118 insertions(+), 130 deletions(-) delete mode 100644 src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java delete mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java delete mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java create mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java diff --git a/build.gradle b/build.gradle index 15636d3..e64ae15 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // Cache + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + // Querydsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' diff --git a/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java b/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java deleted file mode 100644 index 5de7fc4..0000000 --- a/src/main/java/org/sopt/domain/auth/domain/model/AccessTokenBlacklist.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.sopt.domain.auth.domain.model; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import org.sopt.global.entity.BaseTimeEntity; - -import java.time.LocalDateTime; - -/** - * 로그아웃된 access token의 jti를 저장하는 블랙리스트 엔티티. - */ -@Entity -@Table(name = "access_token_blacklists") -public class AccessTokenBlacklist extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 36) - private String tokenId; - - @Column(nullable = false) - private Long userId; - - @Column(nullable = false) - private LocalDateTime expiresAt; - - protected AccessTokenBlacklist() { - } - - /** - * 로그아웃된 access token 정보를 생성한다. - * - * @param tokenId JWT ID - * @param userId 토큰 소유 사용자 ID - * @param expiresAt access token 만료 시각 - */ - public AccessTokenBlacklist(String tokenId, Long userId, LocalDateTime expiresAt) { - this.tokenId = tokenId; - this.userId = userId; - this.expiresAt = expiresAt; - } - -} diff --git a/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java index 3d7d401..7f9862e 100644 --- a/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java +++ b/src/main/java/org/sopt/domain/auth/domain/repository/AccessTokenBlacklistRepository.java @@ -1,25 +1,25 @@ package org.sopt.domain.auth.domain.repository; -import org.sopt.domain.auth.domain.model.AccessTokenBlacklist; +import java.time.LocalDateTime; /** - * access token 블랙리스트 저장소 추상화. + * 로그아웃된 access token을 추적하는 블랙리스트 저장소 추상화. */ public interface AccessTokenBlacklistRepository { /** - * 블랙리스트를 저장한다. + * access token의 JWT ID를 블랙리스트에 등록한다. * - * @param accessTokenBlacklist 저장할 블랙리스트 - * @return 저장된 블랙리스트 + * @param tokenId JWT ID (jti 클레임) + * @param expiresAt access token 만료 시각. 구현체는 이 시각까지만 항목을 유지한다. */ - AccessTokenBlacklist save(AccessTokenBlacklist accessTokenBlacklist); + void add(String tokenId, LocalDateTime expiresAt); /** * JWT ID가 블랙리스트에 존재하는지 확인한다. * * @param tokenId JWT ID - * @return 존재 여부 + * @return 블랙리스트에 존재하면 {@code true} */ - boolean existsByTokenId(String tokenId); + boolean exists(String tokenId); } diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java deleted file mode 100644 index d60b0de..0000000 --- a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.sopt.domain.auth.infrastructure; - -import org.sopt.domain.auth.domain.model.AccessTokenBlacklist; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * AccessTokenBlacklist 엔티티에 대한 Spring Data JPA 저장소. - */ -public interface AccessTokenBlacklistJpaRepository extends JpaRepository { - - /** - * JWT ID가 블랙리스트에 존재하는지 확인한다. - * - * @param tokenId JWT ID - * @return 존재 여부 - */ - boolean existsByTokenId(String tokenId); -} diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java deleted file mode 100644 index f0c7d12..0000000 --- a/src/main/java/org/sopt/domain/auth/infrastructure/AccessTokenBlacklistRepositoryImpl.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.sopt.domain.auth.infrastructure; - -import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.domain.model.AccessTokenBlacklist; -import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; -import org.springframework.stereotype.Repository; - -/** - * access token 블랙리스트 저장소를 JPA 구현체에 연결하는 어댑터. - */ -@Repository -@RequiredArgsConstructor -public class AccessTokenBlacklistRepositoryImpl implements AccessTokenBlacklistRepository { - - private final AccessTokenBlacklistJpaRepository accessTokenBlacklistJpaRepository; - - /** - * 블랙리스트를 저장한다. - * - * @param accessTokenBlacklist 저장할 블랙리스트 - * @return 저장된 블랙리스트 - */ - @Override - public AccessTokenBlacklist save(AccessTokenBlacklist accessTokenBlacklist) { - return accessTokenBlacklistJpaRepository.save(accessTokenBlacklist); - } - - /** - * JWT ID가 블랙리스트에 존재하는지 확인한다. - * - * @param tokenId JWT ID - * @return 존재 여부 - */ - @Override - public boolean existsByTokenId(String tokenId) { - return accessTokenBlacklistJpaRepository.existsByTokenId(tokenId); - } -} diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java new file mode 100644 index 0000000..9a07b57 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java @@ -0,0 +1,52 @@ +package org.sopt.domain.auth.infrastructure; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; + +/** + * Caffeine 로컬 캐시 기반 access token 블랙리스트 구현체. + * + *

각 항목의 TTL을 토큰의 잔여 유효 시간에 정렬하여, + * 토큰이 자연 만료되는 시점에 블랙리스트 항목도 함께 제거된다.

+ */ +@Repository +public class CaffeineAccessTokenBlacklistRepository implements AccessTokenBlacklistRepository { + + private final Cache cache = Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(String key, LocalDateTime expiresAt, long currentTime) { + Duration remaining = Duration.between(LocalDateTime.now(), expiresAt); + return remaining.isNegative() ? 0 : remaining.toNanos(); + } + + @Override + public long expireAfterUpdate(String key, LocalDateTime expiresAt, long currentTime, long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, LocalDateTime expiresAt, long currentTime, long currentDuration) { + return currentDuration; + } + }) + .build(); + + /** {@inheritDoc} */ + @Override + public void add(String tokenId, LocalDateTime expiresAt) { + cache.put(tokenId, expiresAt); + } + + /** {@inheritDoc} */ + @Override + public boolean exists(String tokenId) { + return cache.getIfPresent(tokenId) != null; + } +} diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java index 85c99ef..c90bf31 100644 --- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java +++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java @@ -1,7 +1,6 @@ package org.sopt.domain.user.application.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.domain.model.AccessTokenBlacklist; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; import org.sopt.domain.user.application.dto.CreateUserCommand; @@ -67,13 +66,10 @@ public void deleteUser(AuthenticatedUser authenticatedUser) { User user = findUserOrThrow(authenticatedUser.userId()); user.markDeleted(); refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); - if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) { - accessTokenBlacklistRepository.save(new AccessTokenBlacklist( - authenticatedUser.tokenId(), - authenticatedUser.userId(), - authenticatedUser.accessTokenExpiresAt() - )); - } + accessTokenBlacklistRepository.add( + authenticatedUser.tokenId(), + authenticatedUser.accessTokenExpiresAt() + ); } /** diff --git a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java index 7cf298e..9825768 100644 --- a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java +++ b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java @@ -1,12 +1,14 @@ package org.sopt.global.security.authentication; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; import org.sopt.domain.user.domain.model.User; +import org.sopt.domain.user.domain.model.UserRole; import org.sopt.domain.user.domain.repository.UserRepository; import org.sopt.global.security.exception.JwtAuthenticationException; import org.sopt.global.security.jwt.JwtTokenPayload; @@ -20,13 +22,16 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.Duration; import java.util.List; /** * Authorization Bearer 토큰을 읽어 SecurityContext에 인증 사용자를 저장한다. + * + *

사용자 역할 조회 결과를 Caffeine 캐시(5분 TTL)에 보관하여 + * 매 요청마다 DB를 조회하는 비용을 절감한다.

*/ @Component -@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final BearerTokenResolver bearerTokenResolver; @@ -35,6 +40,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserRepository userRepository; private final AuthenticationEntryPoint authenticationEntryPoint; + private final Cache userRoleCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + public JwtAuthenticationFilter( + BearerTokenResolver bearerTokenResolver, + JwtTokenProvider jwtTokenProvider, + AccessTokenBlacklistRepository accessTokenBlacklistRepository, + UserRepository userRepository, + AuthenticationEntryPoint authenticationEntryPoint + ) { + this.bearerTokenResolver = bearerTokenResolver; + this.jwtTokenProvider = jwtTokenProvider; + this.accessTokenBlacklistRepository = accessTokenBlacklistRepository; + this.userRepository = userRepository; + this.authenticationEntryPoint = authenticationEntryPoint; + } + @Override protected void doFilterInternal( HttpServletRequest request, @@ -45,12 +69,14 @@ protected void doFilterInternal( try { if (token != null) { JwtTokenPayload payload = jwtTokenProvider.getPayload(token, JwtTokenType.ACCESS); - if (accessTokenBlacklistRepository.existsByTokenId(payload.tokenId())) { + if (accessTokenBlacklistRepository.exists(payload.tokenId())) { throw new JwtAuthenticationException("Access token is blacklisted."); } - User user = userRepository.findById(payload.userId()) - .orElseThrow(() -> new JwtAuthenticationException("User is inactive.")); - UsernamePasswordAuthenticationToken authentication = createAuthentication(payload, user); + UserRole role = userRoleCache.get(payload.userId(), this::loadUserRole); + if (role == null) { + throw new JwtAuthenticationException("User is inactive."); + } + UsernamePasswordAuthenticationToken authentication = createAuthentication(payload, role); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtAuthenticationException e) { @@ -61,10 +87,26 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, User user) { + /** + * 사용자 역할 캐시에서 해당 사용자를 제거한다. + * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다. + * + * @param userId 캐시에서 제거할 사용자 ID + */ + public void evictUserRole(Long userId) { + userRoleCache.invalidate(userId); + } + + private UserRole loadUserRole(Long userId) { + return userRepository.findById(userId) + .map(User::getRole) + .orElse(null); + } + + private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, UserRole role) { AuthenticatedUser principal = new AuthenticatedUser( payload.userId(), - user.getRole(), + role, payload.tokenId(), payload.expiresAt() ); @@ -72,7 +114,7 @@ private static UsernamePasswordAuthenticationToken createAuthentication(JwtToken return new UsernamePasswordAuthenticationToken( principal, null, - List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) ); } } From 876ba9fdc35c72ace46c6de54cdc1ec53c0eea77 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:16:45 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20Refresh=20Token=20Reuse=20Detection?= =?UTF-8?q?=EA=B3=BC=20Grace=20Period=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회전된 구 토큰이 재사용되면 사용자의 토큰을 전체 무효화하여 탈취를 감지·차단한다. 회전 직후 10초간 구 토큰을 Grace Period로 허용하여 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다. --- .../auth/application/service/AuthService.java | 79 +++++++++++-------- .../auth/domain/exception/AuthErrorCode.java | 1 + .../RefreshTokenGracePeriodStore.java | 44 +++++++++++ 3 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java index 7da0428..8270d55 100644 --- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java +++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java @@ -1,17 +1,18 @@ package org.sopt.domain.auth.application.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry; import org.sopt.domain.auth.application.dto.AuthTokenResult; import org.sopt.domain.auth.application.dto.OAuthUserProfile; import org.sopt.domain.auth.domain.exception.AuthErrorCode; -import org.sopt.domain.auth.domain.model.AccessTokenBlacklist; import org.sopt.domain.auth.domain.model.OAuthProvider; import org.sopt.domain.auth.domain.model.RefreshToken; import org.sopt.domain.auth.domain.model.SocialAccount; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; import org.sopt.domain.auth.domain.repository.SocialAccountRepository; +import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore; import org.sopt.domain.auth.infrastructure.RefreshTokenHasher; import org.sopt.domain.user.domain.exception.UserErrorCode; import org.sopt.domain.user.domain.model.User; @@ -28,8 +29,18 @@ import java.time.LocalDateTime; /** - * 로그인과 토큰 재발급을 담당하는 인증 서비스. + * 로그인, 토큰 재발급, 로그아웃을 담당하는 인증 서비스. + * + *

Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다. + *

    + *
  • Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
  • + *
  • Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의 + * 토큰을 전체 무효화하여 탈취를 차단한다.
  • + *
  • Grace Period: 회전 직후 10초간 구 토큰을 허용하여 + * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
  • + *
*/ +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -45,11 +56,12 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenHasher refreshTokenHasher; + private final RefreshTokenGracePeriodStore gracePeriodStore; /** * 로그인 ID와 비밀번호를 검증하고 토큰을 발급한다. * - * @param loginId 로그인 ID + * @param loginId 로그인 ID * @param password 평문 비밀번호 * @return 발급된 토큰 */ @@ -94,16 +106,38 @@ public AuthTokenResult loginWithGoogle(String idToken) { /** * refresh token을 검증하고 새 토큰 쌍을 발급한다. * - * @param refreshToken refresh token + *

재발급 흐름: + *

    + *
  1. 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
  2. + *
  3. DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
  4. + *
  5. 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
  6. + *
+ * + * @param refreshToken refresh token 원문 * @return 새로 발급된 토큰 */ public AuthTokenResult reissue(String refreshToken) { Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH); User user = findActiveUser(userId); - RefreshToken savedRefreshToken = findSavedRefreshToken(userId); - validateRefreshToken(refreshToken, savedRefreshToken, userId); + String incomingHash = refreshTokenHasher.hash(refreshToken); + + RefreshToken savedToken = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + + if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash()) + && !savedToken.isExpired()) { + gracePeriodStore.store(incomingHash, userId); + return issueAndSaveTokens(user); + } - return rotateTokens(user); + Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash); + if (graceUserId != null && graceUserId.equals(userId)) { + return issueAndSaveTokens(user); + } + + refreshTokenRepository.deleteByUserId(userId); + log.warn("Refresh token reuse detected: userId={}", userId); + throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED); } /** @@ -113,15 +147,10 @@ public AuthTokenResult reissue(String refreshToken) { */ public void logout(AuthenticatedUser authenticatedUser) { refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); - if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) { - accessTokenBlacklistRepository.save( - new AccessTokenBlacklist( - authenticatedUser.tokenId(), - authenticatedUser.userId(), - authenticatedUser.accessTokenExpiresAt() - ) - ); - } + accessTokenBlacklistRepository.add( + authenticatedUser.tokenId(), + authenticatedUser.accessTokenExpiresAt() + ); } private AuthTokenResult issueAndSaveTokens(User user) { @@ -139,29 +168,11 @@ private AuthTokenResult issueAndSaveTokens(User user) { ); } - private AuthTokenResult rotateTokens(User user) { - return issueAndSaveTokens(user); - } - private User findActiveUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS)); } - private RefreshToken findSavedRefreshToken(Long userId) { - return refreshTokenRepository.findByUserId(userId) - .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN)); - } - - private void validateRefreshToken(String refreshToken, RefreshToken savedRefreshToken, Long userId) { - if (refreshTokenHasher.matches(refreshToken, savedRefreshToken.getTokenHash()) - && !savedRefreshToken.isExpired()) { - return; - } - refreshTokenRepository.deleteByUserId(userId); - throw new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN); - } - private void saveRefreshToken(User user, JwtToken token) { LocalDateTime expiresAt = token.expiresAt(); String tokenHash = refreshTokenHasher.hash(token.value()); diff --git a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java index aef9614..56c256c 100644 --- a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java +++ b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java @@ -11,6 +11,7 @@ public enum AuthErrorCode implements ErrorCode { INVALID_REFRESH_TOKEN("ATH-E001", HttpStatus.UNAUTHORIZED, "refresh token이 올바르지 않습니다."), + REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."), INVALID_ACCESS_TOKEN("ATH-E002", HttpStatus.UNAUTHORIZED, "access token이 올바르지 않습니다."), INVALID_OAUTH_TOKEN("ATH-E003", HttpStatus.UNAUTHORIZED, "OAuth 토큰이 올바르지 않습니다."), OAUTH_CLIENT_NOT_CONFIGURED("ATH-E004", HttpStatus.INTERNAL_SERVER_ERROR, "OAuth 클라이언트 설정이 올바르지 않습니다."), diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java new file mode 100644 index 0000000..d9d4158 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java @@ -0,0 +1,44 @@ +package org.sopt.domain.auth.infrastructure; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 저장소. + * + *

회전 직후 구 토큰의 해시를 짧은 TTL(10초)로 보관한다. + * Grace Period 내에 같은 구 토큰으로 재발급 요청이 들어오면 + * {@link #consumeIfPresent(String)}가 원자적으로 값을 반환하고 키를 제거하여 + * 이중 발급을 방지한다.

+ */ +@Component +public class RefreshTokenGracePeriodStore { + + private final Cache cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(10)) + .maximumSize(10_000) + .build(); + + /** + * 회전된 구 토큰의 해시를 Grace Period 동안 보관한다. + * + * @param tokenHash 회전 전 refresh token의 HMAC 해시 + * @param userId 토큰 소유 사용자 ID + */ + public void store(String tokenHash, Long userId) { + cache.put(tokenHash, userId); + } + + /** + * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다. + * + * @param tokenHash refresh token의 HMAC 해시 + * @return 사용자 ID. Grace Period가 만료되었거나 이미 소비된 경우 {@code null} + */ + public Long consumeIfPresent(String tokenHash) { + return cache.asMap().remove(tokenHash); + } +} From eb0b37259628d18726feddbd25648e951b64bff9 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:20:29 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20AuthService=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EB=AA=85=EC=A3=BC=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=EC=9D=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9개 의존성을 가진 AuthService를 3개 서비스로 분리한다. - AuthTokenService: 토큰 발급, 회전(Rotation + Grace Period), 회수 - SocialLoginService: OAuth 프로필 검증과 자동 회원가입 - AuthService: 자격증명 검증과 흐름 조율 UserCommandService도 AuthTokenService.revoke()를 사용하도록 전환한다. --- .../auth/application/service/AuthService.java | 138 ++--------------- .../application/service/AuthTokenService.java | 141 ++++++++++++++++++ .../service/SocialLoginService.java | 62 ++++++++ .../service/UserCommandService.java | 14 +- 4 files changed, 216 insertions(+), 139 deletions(-) create mode 100644 src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java create mode 100644 src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java index 8270d55..2cf3462 100644 --- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java +++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java @@ -1,62 +1,29 @@ package org.sopt.domain.auth.application.service; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry; import org.sopt.domain.auth.application.dto.AuthTokenResult; -import org.sopt.domain.auth.application.dto.OAuthUserProfile; -import org.sopt.domain.auth.domain.exception.AuthErrorCode; -import org.sopt.domain.auth.domain.model.OAuthProvider; -import org.sopt.domain.auth.domain.model.RefreshToken; -import org.sopt.domain.auth.domain.model.SocialAccount; -import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; -import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; -import org.sopt.domain.auth.domain.repository.SocialAccountRepository; -import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore; -import org.sopt.domain.auth.infrastructure.RefreshTokenHasher; import org.sopt.domain.user.domain.exception.UserErrorCode; import org.sopt.domain.user.domain.model.User; import org.sopt.domain.user.domain.repository.UserRepository; import org.sopt.global.exception.BaseException; import org.sopt.global.security.authentication.AuthenticatedUser; -import org.sopt.global.security.jwt.JwtToken; -import org.sopt.global.security.jwt.JwtTokenProvider; -import org.sopt.global.security.jwt.JwtTokenType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - /** - * 로그인, 토큰 재발급, 로그아웃을 담당하는 인증 서비스. - * - *

Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다. - *

    - *
  • Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
  • - *
  • Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의 - * 토큰을 전체 무효화하여 탈취를 차단한다.
  • - *
  • Grace Period: 회전 직후 10초간 구 토큰을 허용하여 - * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
  • - *
+ * 자격증명 검증과 인증 흐름 조율을 담당하는 서비스. + * 토큰 생명주기는 {@link AuthTokenService}, 소셜 로그인은 {@link SocialLoginService}에 위임한다. */ -@Slf4j @Service @RequiredArgsConstructor @Transactional public class AuthService { - private static final String BEARER_TYPE = "Bearer"; - private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; - private final SocialAccountRepository socialAccountRepository; - private final OAuthProviderClientRegistry oAuthProviderClientRegistry; private final PasswordEncoder passwordEncoder; - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenHasher refreshTokenHasher; - private final RefreshTokenGracePeriodStore gracePeriodStore; + private final AuthTokenService authTokenService; + private final SocialLoginService socialLoginService; /** * 로그인 ID와 비밀번호를 검증하고 토큰을 발급한다. @@ -75,7 +42,7 @@ public AuthTokenResult login(String loginId, String password) { throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS); } - return issueAndSaveTokens(user); + return authTokenService.issue(user); } /** @@ -85,59 +52,18 @@ public AuthTokenResult login(String loginId, String password) { * @return 발급된 토큰 */ public AuthTokenResult loginWithGoogle(String idToken) { - OAuthUserProfile profile = oAuthProviderClientRegistry.getClient(OAuthProvider.GOOGLE).verify(idToken); - User user = socialAccountRepository.findByProviderAndProviderUserId( - profile.provider(), - profile.providerUserId() - ) - .map(socialAccount -> { - socialAccount.updateProfile(profile.email(), profile.profileImageUrl()); - User socialUser = socialAccount.getUser(); - if (socialUser.isDeleted()) { - throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS); - } - return socialUser; - }) - .orElseGet(() -> registerSocialUser(profile)); - - return issueAndSaveTokens(user); + User user = socialLoginService.authenticateWithGoogle(idToken); + return authTokenService.issue(user); } /** * refresh token을 검증하고 새 토큰 쌍을 발급한다. * - *

재발급 흐름: - *

    - *
  1. 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
  2. - *
  3. DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
  4. - *
  5. 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
  6. - *
- * * @param refreshToken refresh token 원문 * @return 새로 발급된 토큰 */ public AuthTokenResult reissue(String refreshToken) { - Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH); - User user = findActiveUser(userId); - String incomingHash = refreshTokenHasher.hash(refreshToken); - - RefreshToken savedToken = refreshTokenRepository.findByUserId(userId) - .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN)); - - if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash()) - && !savedToken.isExpired()) { - gracePeriodStore.store(incomingHash, userId); - return issueAndSaveTokens(user); - } - - Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash); - if (graceUserId != null && graceUserId.equals(userId)) { - return issueAndSaveTokens(user); - } - - refreshTokenRepository.deleteByUserId(userId); - log.warn("Refresh token reuse detected: userId={}", userId); - throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED); + return authTokenService.reissue(refreshToken); } /** @@ -146,52 +72,6 @@ public AuthTokenResult reissue(String refreshToken) { * @param authenticatedUser 인증된 사용자 */ public void logout(AuthenticatedUser authenticatedUser) { - refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); - accessTokenBlacklistRepository.add( - authenticatedUser.tokenId(), - authenticatedUser.accessTokenExpiresAt() - ); - } - - private AuthTokenResult issueAndSaveTokens(User user) { - Long userId = user.getId(); - JwtToken accessToken = jwtTokenProvider.createAccessToken(userId); - JwtToken refreshToken = jwtTokenProvider.createRefreshToken(userId); - saveRefreshToken(user, refreshToken); - - return new AuthTokenResult( - BEARER_TYPE, - accessToken.value(), - accessToken.expiresAt(), - refreshToken.value(), - refreshToken.expiresAt() - ); - } - - private User findActiveUser(Long userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS)); - } - - private void saveRefreshToken(User user, JwtToken token) { - LocalDateTime expiresAt = token.expiresAt(); - String tokenHash = refreshTokenHasher.hash(token.value()); - refreshTokenRepository.findByUserId(user.getId()) - .ifPresentOrElse( - refreshToken -> refreshToken.updateTokenHash(tokenHash, expiresAt), - () -> refreshTokenRepository.save(new RefreshToken(user, tokenHash, expiresAt)) - ); - } - - private User registerSocialUser(OAuthUserProfile profile) { - User user = userRepository.save(User.createSocialUser(profile.nickname())); - socialAccountRepository.save(new SocialAccount( - user, - profile.provider(), - profile.providerUserId(), - profile.email(), - profile.profileImageUrl() - )); - return user; + authTokenService.revoke(authenticatedUser); } } diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java new file mode 100644 index 0000000..3df5352 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java @@ -0,0 +1,141 @@ +package org.sopt.domain.auth.application.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.domain.auth.application.dto.AuthTokenResult; +import org.sopt.domain.auth.domain.exception.AuthErrorCode; +import org.sopt.domain.auth.domain.model.RefreshToken; +import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; +import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; +import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore; +import org.sopt.domain.auth.infrastructure.RefreshTokenHasher; +import org.sopt.domain.user.domain.exception.UserErrorCode; +import org.sopt.domain.user.domain.model.User; +import org.sopt.domain.user.domain.repository.UserRepository; +import org.sopt.global.exception.BaseException; +import org.sopt.global.security.authentication.AuthenticatedUser; +import org.sopt.global.security.jwt.JwtToken; +import org.sopt.global.security.jwt.JwtTokenProvider; +import org.sopt.global.security.jwt.JwtTokenType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 토큰 발급, 회전, 회수를 담당하는 서비스. + * + *

Refresh Token Rotation + Reuse Detection + Grace Period 전략을 적용한다. + *

    + *
  • Rotation: 재발급 시 구 토큰을 즉시 무효화하고 새 토큰을 발급한다.
  • + *
  • Reuse Detection: 이미 회전된 토큰이 다시 사용되면 해당 사용자의 + * 토큰을 전체 무효화하여 탈취를 차단한다.
  • + *
  • Grace Period: 회전 직후 10초간 구 토큰을 허용하여 + * 동시 요청에 의한 정상 사용자 세션 끊김을 방지한다.
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class AuthTokenService { + + private static final String BEARER_TYPE = "Bearer"; + + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenHasher refreshTokenHasher; + private final RefreshTokenGracePeriodStore gracePeriodStore; + + /** + * 사용자에 대한 access token과 refresh token을 발급한다. + * + * @param user 토큰을 발급할 사용자 + * @return 발급된 토큰 + */ + public AuthTokenResult issue(User user) { + return issueAndSaveTokens(user); + } + + /** + * refresh token을 검증하고 새 토큰 쌍을 발급한다. + * + *

재발급 흐름: + *

    + *
  1. 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
  2. + *
  3. DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
  4. + *
  5. 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
  6. + *
+ * + * @param refreshToken refresh token 원문 + * @return 새로 발급된 토큰 + */ + public AuthTokenResult reissue(String refreshToken) { + Long userId = jwtTokenProvider.getUserId(refreshToken, JwtTokenType.REFRESH); + User user = findActiveUser(userId); + String incomingHash = refreshTokenHasher.hash(refreshToken); + + RefreshToken savedToken = refreshTokenRepository.findByUserId(userId) + .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + + if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash()) + && !savedToken.isExpired()) { + gracePeriodStore.store(incomingHash, userId); + return issueAndSaveTokens(user); + } + + Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash); + if (graceUserId != null && graceUserId.equals(userId)) { + return issueAndSaveTokens(user); + } + + refreshTokenRepository.deleteByUserId(userId); + log.warn("Refresh token reuse detected: userId={}", userId); + throw new BaseException(AuthErrorCode.REFRESH_TOKEN_REUSE_DETECTED); + } + + /** + * refresh token을 삭제하고 현재 access token을 블랙리스트에 등록한다. + * + * @param authenticatedUser 인증된 사용자 + */ + public void revoke(AuthenticatedUser authenticatedUser) { + refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); + accessTokenBlacklistRepository.add( + authenticatedUser.tokenId(), + authenticatedUser.accessTokenExpiresAt() + ); + } + + private AuthTokenResult issueAndSaveTokens(User user) { + Long userId = user.getId(); + JwtToken accessToken = jwtTokenProvider.createAccessToken(userId); + JwtToken refreshToken = jwtTokenProvider.createRefreshToken(userId); + saveRefreshToken(user, refreshToken); + + return new AuthTokenResult( + BEARER_TYPE, + accessToken.value(), + accessToken.expiresAt(), + refreshToken.value(), + refreshToken.expiresAt() + ); + } + + private User findActiveUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS)); + } + + private void saveRefreshToken(User user, JwtToken token) { + LocalDateTime expiresAt = token.expiresAt(); + String tokenHash = refreshTokenHasher.hash(token.value()); + refreshTokenRepository.findByUserId(user.getId()) + .ifPresentOrElse( + refreshToken -> refreshToken.updateTokenHash(tokenHash, expiresAt), + () -> refreshTokenRepository.save(new RefreshToken(user, tokenHash, expiresAt)) + ); + } +} diff --git a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java b/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java new file mode 100644 index 0000000..7ac8863 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java @@ -0,0 +1,62 @@ +package org.sopt.domain.auth.application.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry; +import org.sopt.domain.auth.application.dto.OAuthUserProfile; +import org.sopt.domain.auth.domain.model.OAuthProvider; +import org.sopt.domain.auth.domain.model.SocialAccount; +import org.sopt.domain.auth.domain.repository.SocialAccountRepository; +import org.sopt.domain.user.domain.exception.UserErrorCode; +import org.sopt.domain.user.domain.model.User; +import org.sopt.domain.user.domain.repository.UserRepository; +import org.sopt.global.exception.BaseException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * OAuth 소셜 로그인을 통한 사용자 인증과 자동 회원가입을 담당하는 서비스. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class SocialLoginService { + + private final UserRepository userRepository; + private final SocialAccountRepository socialAccountRepository; + private final OAuthProviderClientRegistry oAuthProviderClientRegistry; + + /** + * Google ID Token을 검증하고 소셜 계정 기준으로 사용자를 조회하거나 자동 회원가입한다. + * + * @param idToken Google ID Token + * @return 인증된 사용자 + */ + public User authenticateWithGoogle(String idToken) { + OAuthUserProfile profile = oAuthProviderClientRegistry.getClient(OAuthProvider.GOOGLE).verify(idToken); + return socialAccountRepository.findByProviderAndProviderUserId( + profile.provider(), + profile.providerUserId() + ) + .map(socialAccount -> { + socialAccount.updateProfile(profile.email(), profile.profileImageUrl()); + User user = socialAccount.getUser(); + if (user.isDeleted()) { + throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS); + } + return user; + }) + .orElseGet(() -> registerSocialUser(profile)); + } + + private User registerSocialUser(OAuthUserProfile profile) { + User user = userRepository.save(User.createSocialUser(profile.nickname())); + socialAccountRepository.save(new SocialAccount( + user, + profile.provider(), + profile.providerUserId(), + profile.email(), + profile.profileImageUrl() + )); + return user; + } +} diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java index c90bf31..e299e31 100644 --- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java +++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java @@ -1,8 +1,7 @@ package org.sopt.domain.user.application.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; -import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; +import org.sopt.domain.auth.application.service.AuthTokenService; import org.sopt.domain.user.application.dto.CreateUserCommand; import org.sopt.domain.user.application.dto.UpdateUserCommand; import org.sopt.domain.user.application.dto.UserResult; @@ -27,8 +26,7 @@ public class UserCommandService { private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + private final AuthTokenService authTokenService; private final PasswordEncoder passwordEncoder; /** @@ -58,18 +56,14 @@ public void updateUser(Long authenticatedUserId, UpdateUserCommand command) { } /** - * 인증된 사용자 본인을 소프트 삭제한다. + * 인증된 사용자 본인을 소프트 삭제하고 인증 토큰을 회수한다. * * @param authenticatedUser 인증 사용자 */ public void deleteUser(AuthenticatedUser authenticatedUser) { User user = findUserOrThrow(authenticatedUser.userId()); user.markDeleted(); - refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); - accessTokenBlacklistRepository.add( - authenticatedUser.tokenId(), - authenticatedUser.accessTokenExpiresAt() - ); + authTokenService.revoke(authenticatedUser); } /** From a29aa98089e4b13fe12f98fe9f9638b6f855310e Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:26:42 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20domain=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application Service(AuthService)와 Domain Service(AuthTokenService, SocialLoginService)의 계층을 패키지로 명시한다. - domain/service: 토큰 생명주기, 소셜 로그인 도메인 로직 - domain/port: OAuth 클라이언트 포트 인터페이스 - domain/model: AuthTokenResult 값 객체 - application/service: 유스케이스 조율 (AuthService) --- .../sopt/domain/auth/application/service/AuthService.java | 4 +++- .../{application/dto => domain/model}/AuthTokenResult.java | 2 +- .../client => domain/port}/OAuthProviderClient.java | 4 +--- .../client => domain/port}/OAuthProviderClientRegistry.java | 2 +- .../{application/dto => domain/port}/OAuthUserProfile.java | 2 +- .../{application => domain}/service/AuthTokenService.java | 4 ++-- .../{application => domain}/service/SocialLoginService.java | 6 +++--- .../auth/infrastructure/GoogleOAuthProviderClient.java | 4 ++-- .../domain/auth/presentation/controller/AuthController.java | 2 +- .../auth/presentation/mapper/AuthTokenResponseMapper.java | 2 +- .../domain/user/application/service/UserCommandService.java | 2 +- 11 files changed, 17 insertions(+), 17 deletions(-) rename src/main/java/org/sopt/domain/auth/{application/dto => domain/model}/AuthTokenResult.java (86%) rename src/main/java/org/sopt/domain/auth/{application/client => domain/port}/OAuthProviderClient.java (82%) rename src/main/java/org/sopt/domain/auth/{application/client => domain/port}/OAuthProviderClientRegistry.java (95%) rename src/main/java/org/sopt/domain/auth/{application/dto => domain/port}/OAuthUserProfile.java (87%) rename src/main/java/org/sopt/domain/auth/{application => domain}/service/AuthTokenService.java (98%) rename src/main/java/org/sopt/domain/auth/{application => domain}/service/SocialLoginService.java (92%) diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java index 2cf3462..c49c5b5 100644 --- a/src/main/java/org/sopt/domain/auth/application/service/AuthService.java +++ b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java @@ -1,7 +1,9 @@ package org.sopt.domain.auth.application.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.application.dto.AuthTokenResult; +import org.sopt.domain.auth.domain.model.AuthTokenResult; +import org.sopt.domain.auth.domain.service.AuthTokenService; +import org.sopt.domain.auth.domain.service.SocialLoginService; import org.sopt.domain.user.domain.exception.UserErrorCode; import org.sopt.domain.user.domain.model.User; import org.sopt.domain.user.domain.repository.UserRepository; diff --git a/src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java b/src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java similarity index 86% rename from src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java rename to src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java index 96f59a0..265b432 100644 --- a/src/main/java/org/sopt/domain/auth/application/dto/AuthTokenResult.java +++ b/src/main/java/org/sopt/domain/auth/domain/model/AuthTokenResult.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.application.dto; +package org.sopt.domain.auth.domain.model; import java.time.LocalDateTime; diff --git a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java similarity index 82% rename from src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java index d1fb04a..29c1bea 100644 --- a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClient.java +++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClient.java @@ -1,6 +1,4 @@ -package org.sopt.domain.auth.application.client; - -import org.sopt.domain.auth.application.dto.OAuthUserProfile; +package org.sopt.domain.auth.domain.port; import org.sopt.domain.auth.domain.model.OAuthProvider; /** diff --git a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java similarity index 95% rename from src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java index a9f207a..f6f29af 100644 --- a/src/main/java/org/sopt/domain/auth/application/client/OAuthProviderClientRegistry.java +++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthProviderClientRegistry.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.application.client; +package org.sopt.domain.auth.domain.port; import org.sopt.domain.auth.domain.model.OAuthProvider; import org.sopt.domain.auth.domain.exception.AuthErrorCode; diff --git a/src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java b/src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java similarity index 87% rename from src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java rename to src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java index ab38ff1..7808856 100644 --- a/src/main/java/org/sopt/domain/auth/application/dto/OAuthUserProfile.java +++ b/src/main/java/org/sopt/domain/auth/domain/port/OAuthUserProfile.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.application.dto; +package org.sopt.domain.auth.domain.port; import org.sopt.domain.auth.domain.model.OAuthProvider; diff --git a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java similarity index 98% rename from src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java rename to src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java index 3df5352..954d1cc 100644 --- a/src/main/java/org/sopt/domain/auth/application/service/AuthTokenService.java +++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java @@ -1,8 +1,8 @@ -package org.sopt.domain.auth.application.service; +package org.sopt.domain.auth.domain.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sopt.domain.auth.application.dto.AuthTokenResult; +import org.sopt.domain.auth.domain.model.AuthTokenResult; import org.sopt.domain.auth.domain.exception.AuthErrorCode; import org.sopt.domain.auth.domain.model.RefreshToken; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; diff --git a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java similarity index 92% rename from src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java rename to src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java index 7ac8863..a53dd9b 100644 --- a/src/main/java/org/sopt/domain/auth/application/service/SocialLoginService.java +++ b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java @@ -1,8 +1,8 @@ -package org.sopt.domain.auth.application.service; +package org.sopt.domain.auth.domain.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.application.client.OAuthProviderClientRegistry; -import org.sopt.domain.auth.application.dto.OAuthUserProfile; +import org.sopt.domain.auth.domain.port.OAuthProviderClientRegistry; +import org.sopt.domain.auth.domain.port.OAuthUserProfile; import org.sopt.domain.auth.domain.model.OAuthProvider; import org.sopt.domain.auth.domain.model.SocialAccount; import org.sopt.domain.auth.domain.repository.SocialAccountRepository; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java index ab68431..1c136f4 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java @@ -1,7 +1,7 @@ package org.sopt.domain.auth.infrastructure; -import org.sopt.domain.auth.application.client.OAuthProviderClient; -import org.sopt.domain.auth.application.dto.OAuthUserProfile; +import org.sopt.domain.auth.domain.port.OAuthProviderClient; +import org.sopt.domain.auth.domain.port.OAuthUserProfile; import org.sopt.domain.auth.domain.exception.AuthErrorCode; import org.sopt.domain.auth.domain.model.OAuthProvider; import org.sopt.global.exception.BaseException; diff --git a/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java b/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java index 6eca355..492fdbe 100644 --- a/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java +++ b/src/main/java/org/sopt/domain/auth/presentation/controller/AuthController.java @@ -2,7 +2,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.application.dto.AuthTokenResult; +import org.sopt.domain.auth.domain.model.AuthTokenResult; import org.sopt.domain.auth.application.service.AuthService; import org.sopt.domain.auth.presentation.code.AuthSuccessCode; import org.sopt.domain.auth.presentation.cookie.RefreshTokenCookieFactory; diff --git a/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java b/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java index accf3de..18a00f6 100644 --- a/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java +++ b/src/main/java/org/sopt/domain/auth/presentation/mapper/AuthTokenResponseMapper.java @@ -1,6 +1,6 @@ package org.sopt.domain.auth.presentation.mapper; -import org.sopt.domain.auth.application.dto.AuthTokenResult; +import org.sopt.domain.auth.domain.model.AuthTokenResult; import org.sopt.domain.auth.presentation.dto.response.AuthTokenResponse; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java index e299e31..60ad35c 100644 --- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java +++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java @@ -1,7 +1,7 @@ package org.sopt.domain.user.application.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.application.service.AuthTokenService; +import org.sopt.domain.auth.domain.service.AuthTokenService; import org.sopt.domain.user.application.dto.CreateUserCommand; import org.sopt.domain.user.application.dto.UpdateUserCommand; import org.sopt.domain.user.application.dto.UserResult; From 899b1953a9b5a5f29307708a995d53527fe79f0d Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:44:26 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20Grace=20Period=20=EC=9D=B4=EC=A4=91?= =?UTF-8?q?=20=ED=9A=8C=EC=A0=84=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grace Period 경로에서 issueAndSaveTokens를 재호출하면 정상 회전 시 발급된 refresh token이 덮어써져 첫 번째 응답의 토큰이 무효화되는 문제를 수정한다. Grace Period 캐시에 발급 결과를 함께 저장하여 동시 요청 시 동일한 토큰을 반환하도록 변경한다. 사용자 삭제 시 역할 캐시를 즉시 무효화하여, 삭제된 사용자가 캐시 TTL 동안 인증되는 문제를 수정한다. 캐시를 UserRoleCache 컴포넌트로 추출하여 필터와 서비스 양쪽에서 접근할 수 있도록 한다. --- .../auth/domain/service/AuthTokenService.java | 11 ++-- .../RefreshTokenGracePeriodStore.java | 26 +++++---- .../service/UserCommandService.java | 3 + .../JwtAuthenticationFilter.java | 48 ++-------------- .../authentication/UserRoleCache.java | 55 +++++++++++++++++++ 5 files changed, 83 insertions(+), 60 deletions(-) create mode 100644 src/main/java/org/sopt/global/security/authentication/UserRoleCache.java diff --git a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java index 954d1cc..16d72cd 100644 --- a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java +++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java @@ -82,13 +82,14 @@ public AuthTokenResult reissue(String refreshToken) { if (refreshTokenHasher.matches(refreshToken, savedToken.getTokenHash()) && !savedToken.isExpired()) { - gracePeriodStore.store(incomingHash, userId); - return issueAndSaveTokens(user); + AuthTokenResult result = issueAndSaveTokens(user); + gracePeriodStore.store(incomingHash, result); + return result; } - Long graceUserId = gracePeriodStore.consumeIfPresent(incomingHash); - if (graceUserId != null && graceUserId.equals(userId)) { - return issueAndSaveTokens(user); + AuthTokenResult graceResult = gracePeriodStore.consumeIfPresent(incomingHash); + if (graceResult != null) { + return graceResult; } refreshTokenRepository.deleteByUserId(userId); diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java index d9d4158..a239d96 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java @@ -2,6 +2,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import org.sopt.domain.auth.domain.model.AuthTokenResult; import org.springframework.stereotype.Component; import java.time.Duration; @@ -9,36 +10,37 @@ /** * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 저장소. * - *

회전 직후 구 토큰의 해시를 짧은 TTL(10초)로 보관한다. + *

회전 직후 구 토큰의 해시와 발급된 토큰 결과를 짧은 TTL(10초)로 보관한다. * Grace Period 내에 같은 구 토큰으로 재발급 요청이 들어오면 * {@link #consumeIfPresent(String)}가 원자적으로 값을 반환하고 키를 제거하여 - * 이중 발급을 방지한다.

+ * 재회전 없이 동일한 토큰을 반환한다.

*/ @Component public class RefreshTokenGracePeriodStore { - private final Cache cache = Caffeine.newBuilder() + private final Cache cache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(10)) .maximumSize(10_000) .build(); /** - * 회전된 구 토큰의 해시를 Grace Period 동안 보관한다. + * 회전 시 발급된 토큰 결과를 구 토큰 해시와 함께 Grace Period 동안 보관한다. * - * @param tokenHash 회전 전 refresh token의 HMAC 해시 - * @param userId 토큰 소유 사용자 ID + * @param oldTokenHash 회전 전 refresh token의 HMAC 해시 + * @param result 회전 시 발급된 토큰 결과 */ - public void store(String tokenHash, Long userId) { - cache.put(tokenHash, userId); + public void store(String oldTokenHash, AuthTokenResult result) { + cache.put(oldTokenHash, result); } /** * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다. + * 반환된 토큰은 정상 회전 시 발급된 것과 동일하므로 재회전이 발생하지 않는다. * - * @param tokenHash refresh token의 HMAC 해시 - * @return 사용자 ID. Grace Period가 만료되었거나 이미 소비된 경우 {@code null} + * @param oldTokenHash refresh token의 HMAC 해시 + * @return 회전 시 발급된 토큰 결과. Grace Period가 만료되었거나 이미 소비된 경우 {@code null} */ - public Long consumeIfPresent(String tokenHash) { - return cache.asMap().remove(tokenHash); + public AuthTokenResult consumeIfPresent(String oldTokenHash) { + return cache.asMap().remove(oldTokenHash); } } diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java index 60ad35c..7c20872 100644 --- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java +++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.sopt.domain.auth.domain.service.AuthTokenService; +import org.sopt.global.security.authentication.UserRoleCache; import org.sopt.domain.user.application.dto.CreateUserCommand; import org.sopt.domain.user.application.dto.UpdateUserCommand; import org.sopt.domain.user.application.dto.UserResult; @@ -27,6 +28,7 @@ public class UserCommandService { private final UserRepository userRepository; private final AuthTokenService authTokenService; + private final UserRoleCache userRoleCache; private final PasswordEncoder passwordEncoder; /** @@ -64,6 +66,7 @@ public void deleteUser(AuthenticatedUser authenticatedUser) { User user = findUserOrThrow(authenticatedUser.userId()); user.markDeleted(); authTokenService.revoke(authenticatedUser); + userRoleCache.evict(authenticatedUser.userId()); } /** diff --git a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java index 9825768..cbef80e 100644 --- a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java +++ b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java @@ -1,15 +1,12 @@ package org.sopt.global.security.authentication; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; -import org.sopt.domain.user.domain.model.User; import org.sopt.domain.user.domain.model.UserRole; -import org.sopt.domain.user.domain.repository.UserRepository; import org.sopt.global.security.exception.JwtAuthenticationException; import org.sopt.global.security.jwt.JwtTokenPayload; import org.sopt.global.security.jwt.JwtTokenProvider; @@ -22,43 +19,24 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.time.Duration; import java.util.List; /** * Authorization Bearer 토큰을 읽어 SecurityContext에 인증 사용자를 저장한다. * - *

사용자 역할 조회 결과를 Caffeine 캐시(5분 TTL)에 보관하여 + *

사용자 역할 조회는 {@link UserRoleCache}에 위임하여 * 매 요청마다 DB를 조회하는 비용을 절감한다.

*/ @Component +@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final BearerTokenResolver bearerTokenResolver; private final JwtTokenProvider jwtTokenProvider; private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; - private final UserRepository userRepository; + private final UserRoleCache userRoleCache; private final AuthenticationEntryPoint authenticationEntryPoint; - private final Cache userRoleCache = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofMinutes(5)) - .build(); - - public JwtAuthenticationFilter( - BearerTokenResolver bearerTokenResolver, - JwtTokenProvider jwtTokenProvider, - AccessTokenBlacklistRepository accessTokenBlacklistRepository, - UserRepository userRepository, - AuthenticationEntryPoint authenticationEntryPoint - ) { - this.bearerTokenResolver = bearerTokenResolver; - this.jwtTokenProvider = jwtTokenProvider; - this.accessTokenBlacklistRepository = accessTokenBlacklistRepository; - this.userRepository = userRepository; - this.authenticationEntryPoint = authenticationEntryPoint; - } - @Override protected void doFilterInternal( HttpServletRequest request, @@ -72,7 +50,7 @@ protected void doFilterInternal( if (accessTokenBlacklistRepository.exists(payload.tokenId())) { throw new JwtAuthenticationException("Access token is blacklisted."); } - UserRole role = userRoleCache.get(payload.userId(), this::loadUserRole); + UserRole role = userRoleCache.getRole(payload.userId()); if (role == null) { throw new JwtAuthenticationException("User is inactive."); } @@ -87,22 +65,6 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - /** - * 사용자 역할 캐시에서 해당 사용자를 제거한다. - * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다. - * - * @param userId 캐시에서 제거할 사용자 ID - */ - public void evictUserRole(Long userId) { - userRoleCache.invalidate(userId); - } - - private UserRole loadUserRole(Long userId) { - return userRepository.findById(userId) - .map(User::getRole) - .orElse(null); - } - private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, UserRole role) { AuthenticatedUser principal = new AuthenticatedUser( payload.userId(), diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java new file mode 100644 index 0000000..551f26f --- /dev/null +++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java @@ -0,0 +1,55 @@ +package org.sopt.global.security.authentication; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.sopt.domain.user.domain.model.User; +import org.sopt.domain.user.domain.model.UserRole; +import org.sopt.domain.user.domain.repository.UserRepository; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 인증 필터에서 사용하는 사용자 역할 캐시. + * 매 요청마다 DB를 조회하는 비용을 절감한다. + */ +@Component +public class UserRoleCache { + + private final UserRepository userRepository; + + private final Cache cache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + public UserRoleCache(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 사용자 역할을 조회한다. 캐시에 없으면 DB에서 로드한다. + * + * @param userId 사용자 ID + * @return 사용자 역할. 사용자가 존재하지 않으면 {@code null} + */ + public UserRole getRole(Long userId) { + return cache.get(userId, this::loadUserRole); + } + + /** + * 사용자 역할 캐시에서 해당 사용자를 제거한다. + * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다. + * + * @param userId 캐시에서 제거할 사용자 ID + */ + public void evict(Long userId) { + cache.invalidate(userId); + } + + private UserRole loadUserRole(Long userId) { + return userRepository.findById(userId) + .map(User::getRole) + .orElse(null); + } +} From e028dde1b3cc27c2486b293c953fc5828c4ebdc7 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 22:55:32 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20NPE=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80,=20=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EC=83=81=ED=99=94,?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserRoleCache에서 Caffeine cache.get()이 null 반환 시 NPE를 던지는 버그를 수정한다. getIfPresent + 수동 put으로 전환. - AuthTokenService(domain)이 infrastructure를 직접 참조하던 역방향 의존을 RefreshTokenHashPort, RefreshTokenGracePeriodPort 인터페이스로 추상화하여 제거한다. - AuthErrorCode 선언 순서를 코드 번호순으로 정렬한다. - reissue()의 동시성 제약을 Javadoc에 명시한다. --- .../auth/domain/exception/AuthErrorCode.java | 2 +- .../port/RefreshTokenGracePeriodPort.java | 25 +++++++++++++++++++ .../domain/port/RefreshTokenHashPort.java | 24 ++++++++++++++++++ .../auth/domain/service/AuthTokenService.java | 16 ++++++++---- .../RefreshTokenGracePeriodStore.java | 3 ++- .../infrastructure/RefreshTokenHasher.java | 3 ++- .../authentication/UserRoleCache.java | 10 +++++++- 7 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java create mode 100644 src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java diff --git a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java index 56c256c..d652058 100644 --- a/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java +++ b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java @@ -11,10 +11,10 @@ public enum AuthErrorCode implements ErrorCode { INVALID_REFRESH_TOKEN("ATH-E001", HttpStatus.UNAUTHORIZED, "refresh token이 올바르지 않습니다."), - REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."), INVALID_ACCESS_TOKEN("ATH-E002", HttpStatus.UNAUTHORIZED, "access token이 올바르지 않습니다."), INVALID_OAUTH_TOKEN("ATH-E003", HttpStatus.UNAUTHORIZED, "OAuth 토큰이 올바르지 않습니다."), OAUTH_CLIENT_NOT_CONFIGURED("ATH-E004", HttpStatus.INTERNAL_SERVER_ERROR, "OAuth 클라이언트 설정이 올바르지 않습니다."), + REFRESH_TOKEN_REUSE_DETECTED("ATH-E005", HttpStatus.UNAUTHORIZED, "refresh token 재사용이 감지되어 세션이 무효화되었습니다."), ; private final String code; diff --git a/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java new file mode 100644 index 0000000..846dce2 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenGracePeriodPort.java @@ -0,0 +1,25 @@ +package org.sopt.domain.auth.domain.port; + +import org.sopt.domain.auth.domain.model.AuthTokenResult; + +/** + * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 포트. + */ +public interface RefreshTokenGracePeriodPort { + + /** + * 회전 시 발급된 토큰 결과를 구 토큰 해시와 함께 Grace Period 동안 보관한다. + * + * @param oldTokenHash 회전 전 refresh token의 HMAC 해시 + * @param result 회전 시 발급된 토큰 결과 + */ + void store(String oldTokenHash, AuthTokenResult result); + + /** + * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다. + * + * @param oldTokenHash refresh token의 HMAC 해시 + * @return 회전 시 발급된 토큰 결과. Grace Period가 만료되었거나 이미 소비된 경우 {@code null} + */ + AuthTokenResult consumeIfPresent(String oldTokenHash); +} diff --git a/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java new file mode 100644 index 0000000..4847cb2 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/domain/port/RefreshTokenHashPort.java @@ -0,0 +1,24 @@ +package org.sopt.domain.auth.domain.port; + +/** + * refresh token 원문을 저장용 해시로 변환하는 포트. + */ +public interface RefreshTokenHashPort { + + /** + * refresh token 원문으로부터 저장용 해시 값을 만든다. + * + * @param refreshToken refresh token 원문 + * @return 저장용 해시 값 + */ + String hash(String refreshToken); + + /** + * refresh token 원문과 저장된 해시 값을 비교한다. + * + * @param refreshToken refresh token 원문 + * @param expectedHash 저장된 해시 값 + * @return 일치 여부 + */ + boolean matches(String refreshToken, String expectedHash); +} diff --git a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java index 16d72cd..f092e8e 100644 --- a/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java +++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java @@ -7,8 +7,8 @@ import org.sopt.domain.auth.domain.model.RefreshToken; import org.sopt.domain.auth.domain.repository.AccessTokenBlacklistRepository; import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; -import org.sopt.domain.auth.infrastructure.RefreshTokenGracePeriodStore; -import org.sopt.domain.auth.infrastructure.RefreshTokenHasher; +import org.sopt.domain.auth.domain.port.RefreshTokenGracePeriodPort; +import org.sopt.domain.auth.domain.port.RefreshTokenHashPort; import org.sopt.domain.user.domain.exception.UserErrorCode; import org.sopt.domain.user.domain.model.User; import org.sopt.domain.user.domain.repository.UserRepository; @@ -46,8 +46,8 @@ public class AuthTokenService { private final RefreshTokenRepository refreshTokenRepository; private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenHasher refreshTokenHasher; - private final RefreshTokenGracePeriodStore gracePeriodStore; + private final RefreshTokenHashPort refreshTokenHasher; + private final RefreshTokenGracePeriodPort gracePeriodStore; /** * 사용자에 대한 access token과 refresh token을 발급한다. @@ -65,10 +65,16 @@ public AuthTokenResult issue(User user) { *

재발급 흐름: *

    *
  1. 현재 DB의 해시와 일치하면 정상 회전 후 구 해시를 Grace Period에 등록한다.
  2. - *
  3. DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 허용한다.
  4. + *
  5. DB 해시와 불일치하지만 Grace Period에 존재하면 동시 요청으로 간주해 + * 재회전 없이 기존 발급 결과를 반환한다.
  6. *
  7. 양쪽 모두 실패하면 Reuse로 판단, 사용자의 토큰을 전체 무효화한다.
  8. *
* + *

동시성 참고: 같은 refresh token을 두 스레드가 동시에 정상 경로(1단계)로 + * 통과하면 두 번째 스레드가 첫 번째의 토큰을 덮어쓸 수 있다. + * 트래픽이 높아지면 {@code findByUserId}에 비관적 락(SELECT FOR UPDATE)을 적용해 + * 직렬화하는 것을 고려해야 한다.

+ * * @param refreshToken refresh token 원문 * @return 새로 발급된 토큰 */ diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java index a239d96..6ff750f 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java @@ -3,6 +3,7 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.sopt.domain.auth.domain.model.AuthTokenResult; +import org.sopt.domain.auth.domain.port.RefreshTokenGracePeriodPort; import org.springframework.stereotype.Component; import java.time.Duration; @@ -16,7 +17,7 @@ * 재회전 없이 동일한 토큰을 반환한다.

*/ @Component -public class RefreshTokenGracePeriodStore { +public class RefreshTokenGracePeriodStore implements RefreshTokenGracePeriodPort { private final Cache cache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(10)) diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java index f57f637..ee10369 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java @@ -1,5 +1,6 @@ package org.sopt.domain.auth.infrastructure; +import org.sopt.domain.auth.domain.port.RefreshTokenHashPort; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -14,7 +15,7 @@ * refresh token 원문을 DB에 저장하지 않기 위한 HMAC 해시 유틸리티. */ @Component -public class RefreshTokenHasher { +public class RefreshTokenHasher implements RefreshTokenHashPort { private static final String HMAC_ALGORITHM = "HmacSHA256"; diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java index 551f26f..22789ad 100644 --- a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java +++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java @@ -34,7 +34,15 @@ public UserRoleCache(UserRepository userRepository) { * @return 사용자 역할. 사용자가 존재하지 않으면 {@code null} */ public UserRole getRole(Long userId) { - return cache.get(userId, this::loadUserRole); + UserRole cached = cache.getIfPresent(userId); + if (cached != null) { + return cached; + } + UserRole role = loadUserRole(userId); + if (role != null) { + cache.put(userId, role); + } + return role; } /** From e64bd6e092ddb8cd17a8c6a86d5cd01b9b49b044 Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 23:21:52 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=AD=ED=95=A0=20=EC=BA=90=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../application/port/AuthSessionPort.java | 16 ++++++++++ .../service/UserCommandService.java | 9 ++---- .../adapter/AuthSessionPortAdapter.java | 31 +++++++++++++++++++ .../authentication/UserRoleCache.java | 19 ++++-------- 5 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java create mode 100644 src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java diff --git a/build.gradle b/build.gradle index e64ae15..5157009 100644 --- a/build.gradle +++ b/build.gradle @@ -44,7 +44,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' // Cache - implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + implementation 'com.github.ben-manes.caffeine:caffeine' // Querydsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' diff --git a/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java b/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java new file mode 100644 index 0000000..68814ae --- /dev/null +++ b/src/main/java/org/sopt/domain/user/application/port/AuthSessionPort.java @@ -0,0 +1,16 @@ +package org.sopt.domain.user.application.port; + +import org.sopt.global.security.authentication.AuthenticatedUser; + +/** + * User 유스케이스가 인증 세션 회수 구현에 직접 의존하지 않도록 분리한 포트. + */ +public interface AuthSessionPort { + + /** + * 인증된 사용자의 현재 인증 세션을 회수한다. + * + * @param authenticatedUser 인증 사용자 + */ + void revoke(AuthenticatedUser authenticatedUser); +} diff --git a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java index 7c20872..ac0f1a3 100644 --- a/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java +++ b/src/main/java/org/sopt/domain/user/application/service/UserCommandService.java @@ -1,12 +1,11 @@ package org.sopt.domain.user.application.service; import lombok.RequiredArgsConstructor; -import org.sopt.domain.auth.domain.service.AuthTokenService; -import org.sopt.global.security.authentication.UserRoleCache; import org.sopt.domain.user.application.dto.CreateUserCommand; import org.sopt.domain.user.application.dto.UpdateUserCommand; import org.sopt.domain.user.application.dto.UserResult; import org.sopt.domain.user.application.mapper.UserResultMapper; +import org.sopt.domain.user.application.port.AuthSessionPort; import org.sopt.domain.user.domain.exception.UserErrorCode; import org.sopt.domain.user.domain.exception.UserNotFoundException; import org.sopt.domain.user.domain.model.User; @@ -27,8 +26,7 @@ public class UserCommandService { private final UserRepository userRepository; - private final AuthTokenService authTokenService; - private final UserRoleCache userRoleCache; + private final AuthSessionPort authSessionPort; private final PasswordEncoder passwordEncoder; /** @@ -65,8 +63,7 @@ public void updateUser(Long authenticatedUserId, UpdateUserCommand command) { public void deleteUser(AuthenticatedUser authenticatedUser) { User user = findUserOrThrow(authenticatedUser.userId()); user.markDeleted(); - authTokenService.revoke(authenticatedUser); - userRoleCache.evict(authenticatedUser.userId()); + authSessionPort.revoke(authenticatedUser); } /** diff --git a/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java b/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java new file mode 100644 index 0000000..31e8c53 --- /dev/null +++ b/src/main/java/org/sopt/domain/user/infrastructure/adapter/AuthSessionPortAdapter.java @@ -0,0 +1,31 @@ +package org.sopt.domain.user.infrastructure.adapter; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.auth.domain.service.AuthTokenService; +import org.sopt.domain.user.application.port.AuthSessionPort; +import org.sopt.global.security.authentication.AuthenticatedUser; +import org.sopt.global.security.authentication.UserRoleCache; +import org.springframework.stereotype.Component; + +/** + * User 도메인용 {@link AuthSessionPort} 구현체. + * 회원 탈퇴 시 필요한 인증 토큰 회수와 역할 캐시 무효화를 인증 계층에 위임한다. + */ +@Component +@RequiredArgsConstructor +public class AuthSessionPortAdapter implements AuthSessionPort { + + private final AuthTokenService authTokenService; + private final UserRoleCache userRoleCache; + + /** + * 현재 access token을 블랙리스트에 등록하고 refresh token과 역할 캐시를 제거한다. + * + * @param authenticatedUser 인증 사용자 + */ + @Override + public void revoke(AuthenticatedUser authenticatedUser) { + authTokenService.revoke(authenticatedUser); + userRoleCache.evict(authenticatedUser.userId()); + } +} diff --git a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java index 22789ad..1b374f1 100644 --- a/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java +++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import java.time.Duration; +import java.util.Optional; /** * 인증 필터에서 사용하는 사용자 역할 캐시. @@ -18,7 +19,7 @@ public class UserRoleCache { private final UserRepository userRepository; - private final Cache cache = Caffeine.newBuilder() + private final Cache> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); @@ -34,15 +35,8 @@ public UserRoleCache(UserRepository userRepository) { * @return 사용자 역할. 사용자가 존재하지 않으면 {@code null} */ public UserRole getRole(Long userId) { - UserRole cached = cache.getIfPresent(userId); - if (cached != null) { - return cached; - } - UserRole role = loadUserRole(userId); - if (role != null) { - cache.put(userId, role); - } - return role; + return cache.get(userId, this::loadUserRole) + .orElse(null); } /** @@ -55,9 +49,8 @@ public void evict(Long userId) { cache.invalidate(userId); } - private UserRole loadUserRole(Long userId) { + private Optional loadUserRole(Long userId) { return userRepository.findById(userId) - .map(User::getRole) - .orElse(null); + .map(User::getRole); } } From 37215aa003e6658265ad5cfcf2981b42ad0ce05a Mon Sep 17 00:00:00 2001 From: Kimgyuilli Date: Sun, 24 May 2026 23:29:45 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20infrastructure=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20=EA=B4=80=EC=8B=AC=EC=82=AC?= =?UTF-8?q?=EB=B3=84=20=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8개 파일이 혼재된 infrastructure 패키지를 정리한다. - persistence: JPA 레포지토리와 어댑터 - cache: Caffeine 기반 블랙리스트·Grace Period 구현체 - oauth: Google OAuth 클라이언트 - security: Refresh Token HMAC 해셔 --- .../{ => cache}/CaffeineAccessTokenBlacklistRepository.java | 2 +- .../{ => cache}/RefreshTokenGracePeriodStore.java | 2 +- .../infrastructure/{ => oauth}/GoogleOAuthProviderClient.java | 2 +- .../{ => persistence}/RefreshTokenJpaRepository.java | 2 +- .../{ => persistence}/RefreshTokenRepositoryImpl.java | 2 +- .../{ => persistence}/SocialAccountJpaRepository.java | 2 +- .../{ => persistence}/SocialAccountRepositoryImpl.java | 2 +- .../auth/infrastructure/{ => security}/RefreshTokenHasher.java | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => cache}/CaffeineAccessTokenBlacklistRepository.java (97%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => cache}/RefreshTokenGracePeriodStore.java (97%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => oauth}/GoogleOAuthProviderClient.java (99%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/RefreshTokenJpaRepository.java (92%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/RefreshTokenRepositoryImpl.java (95%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/SocialAccountJpaRepository.java (92%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => persistence}/SocialAccountRepositoryImpl.java (96%) rename src/main/java/org/sopt/domain/auth/infrastructure/{ => security}/RefreshTokenHasher.java (97%) diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java similarity index 97% rename from src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java rename to src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java index 9a07b57..5f7fd28 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/CaffeineAccessTokenBlacklistRepository.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.cache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java similarity index 97% rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java rename to src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java index 6ff750f..2d36214 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenGracePeriodStore.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.cache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java b/src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java similarity index 99% rename from src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java rename to src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java index 1c136f4..4e2e5cc 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/GoogleOAuthProviderClient.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/oauth/GoogleOAuthProviderClient.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.oauth; import org.sopt.domain.auth.domain.port.OAuthProviderClient; import org.sopt.domain.auth.domain.port.OAuthUserProfile; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java similarity index 92% rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java index e3649c2..0cec217 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenJpaRepository.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenJpaRepository.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.persistence; import org.sopt.domain.auth.domain.model.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java similarity index 95% rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java index e8410c6..67b6d40 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenRepositoryImpl.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/RefreshTokenRepositoryImpl.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.persistence; import lombok.RequiredArgsConstructor; import org.sopt.domain.auth.domain.model.RefreshToken; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java similarity index 92% rename from src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java index b84bb51..6d308f6 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountJpaRepository.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountJpaRepository.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.persistence; import org.sopt.domain.auth.domain.model.OAuthProvider; import org.sopt.domain.auth.domain.model.SocialAccount; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java similarity index 96% rename from src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java rename to src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java index 25550e9..f9bf8fd 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/SocialAccountRepositoryImpl.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/persistence/SocialAccountRepositoryImpl.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.persistence; import lombok.RequiredArgsConstructor; import org.sopt.domain.auth.domain.model.OAuthProvider; diff --git a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java b/src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java similarity index 97% rename from src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java rename to src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java index ee10369..a51f22d 100644 --- a/src/main/java/org/sopt/domain/auth/infrastructure/RefreshTokenHasher.java +++ b/src/main/java/org/sopt/domain/auth/infrastructure/security/RefreshTokenHasher.java @@ -1,4 +1,4 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.security; import org.sopt.domain.auth.domain.port.RefreshTokenHashPort; import org.springframework.beans.factory.annotation.Value;