diff --git a/build.gradle b/build.gradle index 15636d3..5157009 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' + // 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/application/service/AuthService.java b/src/main/java/org/sopt/domain/auth/application/service/AuthService.java index 7da0428..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,55 +1,36 @@ 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.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.RefreshTokenHasher; +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; 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; - /** - * 로그인과 토큰 재발급을 담당하는 인증 서비스. + * 자격증명 검증과 인증 흐름 조율을 담당하는 서비스. + * 토큰 생명주기는 {@link AuthTokenService}, 소셜 로그인은 {@link SocialLoginService}에 위임한다. */ @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 AuthTokenService authTokenService; + private final SocialLoginService socialLoginService; /** * 로그인 ID와 비밀번호를 검증하고 토큰을 발급한다. * - * @param loginId 로그인 ID + * @param loginId 로그인 ID * @param password 평문 비밀번호 * @return 발급된 토큰 */ @@ -63,7 +44,7 @@ public AuthTokenResult login(String loginId, String password) { throw new BaseException(UserErrorCode.INVALID_LOGIN_CREDENTIALS); } - return issueAndSaveTokens(user); + return authTokenService.issue(user); } /** @@ -73,37 +54,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을 검증하고 새 토큰 쌍을 발급한다. * - * @param refreshToken refresh token + * @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); - - return rotateTokens(user); + return authTokenService.reissue(refreshToken); } /** @@ -112,75 +74,6 @@ public AuthTokenResult reissue(String refreshToken) { * @param authenticatedUser 인증된 사용자 */ public void logout(AuthenticatedUser authenticatedUser) { - refreshTokenRepository.deleteByUserId(authenticatedUser.userId()); - if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) { - accessTokenBlacklistRepository.save( - new AccessTokenBlacklist( - authenticatedUser.tokenId(), - authenticatedUser.userId(), - 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 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()); - 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/domain/exception/AuthErrorCode.java b/src/main/java/org/sopt/domain/auth/domain/exception/AuthErrorCode.java index aef9614..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 @@ -14,6 +14,7 @@ public enum AuthErrorCode implements ErrorCode { 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/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/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/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/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/domain/service/AuthTokenService.java b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java new file mode 100644 index 0000000..f092e8e --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/domain/service/AuthTokenService.java @@ -0,0 +1,148 @@ +package org.sopt.domain.auth.domain.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; +import org.sopt.domain.auth.domain.repository.RefreshTokenRepository; +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; +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 전략을 적용한다. + *

+ */ +@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 RefreshTokenHashPort refreshTokenHasher; + private final RefreshTokenGracePeriodPort 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. + *
+ * + *

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

+ * + * @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()) { + AuthTokenResult result = issueAndSaveTokens(user); + gracePeriodStore.store(incomingHash, result); + return result; + } + + AuthTokenResult graceResult = gracePeriodStore.consumeIfPresent(incomingHash); + if (graceResult != null) { + return graceResult; + } + + 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/domain/service/SocialLoginService.java b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java new file mode 100644 index 0000000..a53dd9b --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/domain/service/SocialLoginService.java @@ -0,0 +1,62 @@ +package org.sopt.domain.auth.domain.service; + +import lombok.RequiredArgsConstructor; +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; +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/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/cache/CaffeineAccessTokenBlacklistRepository.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java new file mode 100644 index 0000000..5f7fd28 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/CaffeineAccessTokenBlacklistRepository.java @@ -0,0 +1,52 @@ +package org.sopt.domain.auth.infrastructure.cache; + +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/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java new file mode 100644 index 0000000..2d36214 --- /dev/null +++ b/src/main/java/org/sopt/domain/auth/infrastructure/cache/RefreshTokenGracePeriodStore.java @@ -0,0 +1,47 @@ +package org.sopt.domain.auth.infrastructure.cache; + +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; + +/** + * Refresh Token Rotation 시 동시 요청을 흡수하기 위한 Grace Period 저장소. + * + *

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

+ */ +@Component +public class RefreshTokenGracePeriodStore implements RefreshTokenGracePeriodPort { + + private final Cache cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(10)) + .maximumSize(10_000) + .build(); + + /** + * 회전 시 발급된 토큰 결과를 구 토큰 해시와 함께 Grace Period 동안 보관한다. + * + * @param oldTokenHash 회전 전 refresh token의 HMAC 해시 + * @param result 회전 시 발급된 토큰 결과 + */ + public void store(String oldTokenHash, AuthTokenResult result) { + cache.put(oldTokenHash, result); + } + + /** + * Grace Period 캐시에서 토큰 해시를 원자적으로 꺼내고 제거한다. + * 반환된 토큰은 정상 회전 시 발급된 것과 동일하므로 재회전이 발생하지 않는다. + * + * @param oldTokenHash refresh token의 HMAC 해시 + * @return 회전 시 발급된 토큰 결과. Grace Period가 만료되었거나 이미 소비된 경우 {@code null} + */ + public AuthTokenResult consumeIfPresent(String oldTokenHash) { + return cache.asMap().remove(oldTokenHash); + } +} 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 96% 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 ab68431..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,7 +1,7 @@ -package org.sopt.domain.auth.infrastructure; +package org.sopt.domain.auth.infrastructure.oauth; -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/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 92% 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 f57f637..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,5 +1,6 @@ -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; 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/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/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 85c99ef..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,13 +1,11 @@ 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; 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; @@ -28,8 +26,7 @@ public class UserCommandService { private final UserRepository userRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final AccessTokenBlacklistRepository accessTokenBlacklistRepository; + private final AuthSessionPort authSessionPort; private final PasswordEncoder passwordEncoder; /** @@ -59,21 +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()); - if (!accessTokenBlacklistRepository.existsByTokenId(authenticatedUser.tokenId())) { - accessTokenBlacklistRepository.save(new AccessTokenBlacklist( - authenticatedUser.tokenId(), - authenticatedUser.userId(), - authenticatedUser.accessTokenExpiresAt() - )); - } + 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/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(); } } 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..cbef80e 100644 --- a/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java +++ b/src/main/java/org/sopt/global/security/authentication/JwtAuthenticationFilter.java @@ -6,8 +6,7 @@ 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.repository.UserRepository; +import org.sopt.domain.user.domain.model.UserRole; import org.sopt.global.security.exception.JwtAuthenticationException; import org.sopt.global.security.jwt.JwtTokenPayload; import org.sopt.global.security.jwt.JwtTokenProvider; @@ -24,6 +23,9 @@ /** * Authorization Bearer 토큰을 읽어 SecurityContext에 인증 사용자를 저장한다. + * + *

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

*/ @Component @RequiredArgsConstructor @@ -32,7 +34,7 @@ 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; @Override @@ -45,12 +47,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.getRole(payload.userId()); + if (role == null) { + throw new JwtAuthenticationException("User is inactive."); + } + UsernamePasswordAuthenticationToken authentication = createAuthentication(payload, role); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (JwtAuthenticationException e) { @@ -61,10 +65,10 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, User user) { + private static UsernamePasswordAuthenticationToken createAuthentication(JwtTokenPayload payload, UserRole role) { AuthenticatedUser principal = new AuthenticatedUser( payload.userId(), - user.getRole(), + role, payload.tokenId(), payload.expiresAt() ); @@ -72,7 +76,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())) ); } } 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..1b374f1 --- /dev/null +++ b/src/main/java/org/sopt/global/security/authentication/UserRoleCache.java @@ -0,0 +1,56 @@ +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; +import java.util.Optional; + +/** + * 인증 필터에서 사용하는 사용자 역할 캐시. + * 매 요청마다 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) + .orElse(null); + } + + /** + * 사용자 역할 캐시에서 해당 사용자를 제거한다. + * 역할 변경이나 사용자 삭제 시 호출하여 캐시 정합성을 유지한다. + * + * @param userId 캐시에서 제거할 사용자 ID + */ + public void evict(Long userId) { + cache.invalidate(userId); + } + + private Optional loadUserRole(Long userId) { + return userRepository.findById(userId) + .map(User::getRole); + } +}