Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
135 changes: 14 additions & 121 deletions src/main/java/org/sopt/domain/auth/application/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -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 발급된 토큰
*/
Expand All @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.sopt.domain.auth.application.dto;
package org.sopt.domain.auth.domain.model;

import java.time.LocalDateTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading