Repository navigation
feat: 재발급 토큰 엔티티 RefreshToken 추가 (#32) #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.sniptext.domain.auth.entity; | ||
|
|
||
| /** 재발급 토큰을 받은 곳. 로그아웃은 둘 다 폐기한다 (명세 4번). 확장 전용 발급 방식은 S2에서 정한다. */ | ||
| public enum ClientType { | ||
|
|
||
| WEB, | ||
|
|
||
| EXTENSION | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package com.sniptext.domain.auth.entity; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import org.hibernate.annotations.JdbcTypeCode; | ||
| import org.hibernate.type.SqlTypes; | ||
|
|
||
| import com.sniptext.global.entity.BaseCreatedTimeEntity; | ||
|
|
||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.EnumType; | ||
| import jakarta.persistence.Enumerated; | ||
| import jakarta.persistence.GeneratedValue; | ||
| import jakarta.persistence.GenerationType; | ||
| import jakarta.persistence.Id; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| /** | ||
| * 재발급 토큰 (14일). 원문은 저장하지 않고 SHA-256 해시(16진수 64자)만 저장한다. | ||
| * 재발급할 때마다 새 토큰으로 바꾸고 이전 것은 폐기하며, 같은 로그인에서 이어진 토큰은 family_id가 같다. | ||
| * 이미 폐기된 토큰이 다시 오면 탈취로 보고 그 계보 전체를 폐기한다 (명세 3번). | ||
| */ | ||
| @Getter | ||
| @Entity | ||
| @Table(name = "refresh_tokens") | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class RefreshToken extends BaseCreatedTimeEntity { | ||
|
|
||
| public static final int TOKEN_HASH_LENGTH = 64; | ||
| public static final int FAMILY_ID_LENGTH = 36; | ||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| @Column(name = "user_id", nullable = false) | ||
| private Long userId; | ||
|
|
||
| @Enumerated(EnumType.STRING) | ||
| @Column(name = "client_type", nullable = false, length = 20) | ||
| private ClientType clientType; | ||
|
|
||
| // 컬럼이 CHAR라 VARCHAR로 매핑하면 스키마 검증(validate)에서 기동이 멈춘다 | ||
| @JdbcTypeCode(SqlTypes.CHAR) | ||
| @Column(name = "token_hash", nullable = false, length = TOKEN_HASH_LENGTH) | ||
| private String tokenHash; | ||
|
|
||
| @JdbcTypeCode(SqlTypes.CHAR) | ||
| @Column(name = "family_id", nullable = false, length = FAMILY_ID_LENGTH) | ||
| private String familyId; | ||
|
|
||
| @Column(name = "expires_at", nullable = false) | ||
| private LocalDateTime expiresAt; | ||
|
|
||
| /** 로그아웃·교체·재사용 감지로 폐기된 시각. null이면 아직 쓸 수 있다 (만료는 따로 본다) */ | ||
| @Column(name = "revoked_at") | ||
| private LocalDateTime revokedAt; | ||
|
|
||
| @Builder(access = AccessLevel.PRIVATE) | ||
| private RefreshToken(Long userId, ClientType clientType, String tokenHash, String familyId, | ||
| LocalDateTime expiresAt) { | ||
| this.userId = userId; | ||
| this.clientType = clientType; | ||
| this.tokenHash = tokenHash; | ||
| this.familyId = familyId; | ||
| this.expiresAt = expiresAt; | ||
| } | ||
|
|
||
| /** 여기서 실패하면 토큰 발급 코드의 버그라 IllegalArgumentException(500)이다. 메시지에 해시를 넣지 않는다. */ | ||
| public static RefreshToken create(Long userId, ClientType clientType, String tokenHash, String familyId, | ||
| LocalDateTime expiresAt) { | ||
| require(userId != null, "회원 ID가 없다"); | ||
| require(clientType != null, "발급처가 없다"); | ||
| require(tokenHash != null && tokenHash.length() == TOKEN_HASH_LENGTH, "토큰 해시가 64자가 아니다"); | ||
| require(familyId != null && familyId.length() == FAMILY_ID_LENGTH, "계보 ID가 36자가 아니다"); | ||
| require(expiresAt != null, "만료 시각이 없다"); | ||
| return RefreshToken.builder() | ||
| .userId(userId) | ||
| .clientType(clientType) | ||
| .tokenHash(tokenHash) | ||
| .familyId(familyId) | ||
| .expiresAt(expiresAt) | ||
| .build(); | ||
| } | ||
|
|
||
| /** 이미 폐기됐으면 처음 폐기한 시각을 그대로 둔다. */ | ||
| public void revoke(LocalDateTime now) { | ||
| require(now != null, "폐기 시각이 없다"); | ||
| if (revokedAt == null) { | ||
| revokedAt = now; | ||
| } | ||
| } | ||
|
|
||
| public boolean isRevoked() { | ||
| return revokedAt != null; | ||
| } | ||
|
|
||
| /** 만료 시각과 같으면 만료다. */ | ||
| public boolean isExpired(LocalDateTime now) { | ||
| return !now.isBefore(expiresAt); | ||
| } | ||
|
|
||
| private static void require(boolean condition, String message) { | ||
| if (!condition) { | ||
| throw new IllegalArgumentException("재발급 토큰 규칙 위반: " + message); | ||
| } | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.sniptext.domain.auth.repository; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.Optional; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Modifying; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| import com.sniptext.domain.auth.entity.RefreshToken; | ||
|
|
||
| public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> { | ||
|
|
||
| Optional<RefreshToken> findByTokenHash(String tokenHash); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 재발급 서비스에서 이 메서드로 토큰을 찾고 둘 중 하나로 막으면 될 것 같습니다!
이번 PR에 같이 넣어도 되고, 재발급 PR에서 넣어도 괜찮습니다. 어느 쪽으로 할지만 정해 주세요! |
||
|
|
||
| /** | ||
| * 계보 전체를 폐기한다 (이미 쓴 토큰의 재사용 감지). 이미 폐기된 토큰은 처음 폐기 시각을 그대로 둔다. | ||
| * refresh_tokens에는 updated_at이 없어 직접 넣을 시각 컬럼이 따로 없다. | ||
| */ | ||
| @Modifying(flushAutomatically = true, clearAutomatically = true) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| @Query("update RefreshToken t set t.revokedAt = :now where t.familyId = :familyId and t.revokedAt is null") | ||
| int revokeFamily(@Param("familyId") String familyId, @Param("now") LocalDateTime now); | ||
|
|
||
| /** 회원의 재발급 토큰을 웹·확장 구분 없이 모두 폐기한다 (로그아웃). */ | ||
| @Modifying(flushAutomatically = true, clearAutomatically = true) | ||
| @Query("update RefreshToken t set t.revokedAt = :now where t.userId = :userId and t.revokedAt is null") | ||
| int revokeAllByUserId(@Param("userId") Long userId, @Param("now") LocalDateTime now); | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| package com.sniptext.domain.auth.entity; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.ValueSource; | ||
|
|
||
| import com.sniptext.fixture.RefreshTokenFixture; | ||
|
|
||
| class RefreshTokenTest { | ||
|
|
||
| private static final String HASH = RefreshTokenFixture.hash(1); | ||
| private static final String FAMILY_ID = RefreshTokenFixture.FAMILY_ID; | ||
| private static final LocalDateTime EXPIRES_AT = LocalDateTime.of(2026, 11, 8, 10, 0); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 픽스처에 같은 값( |
||
|
|
||
| @Test | ||
| @DisplayName("재발급 토큰을 만들면 받은 값이 들어가고 폐기되지 않은 상태로 시작한다") | ||
| void create() { | ||
| RefreshToken token = RefreshToken.create(1L, ClientType.WEB, HASH, FAMILY_ID, EXPIRES_AT); | ||
|
|
||
| assertThat(token.getUserId()).isEqualTo(1L); | ||
| assertThat(token.getClientType()).isEqualTo(ClientType.WEB); | ||
| assertThat(token.getTokenHash()).isEqualTo(HASH); | ||
| assertThat(token.getFamilyId()).isEqualTo(FAMILY_ID); | ||
| assertThat(token.getExpiresAt()).isEqualTo(EXPIRES_AT); | ||
| assertThat(token.isRevoked()).isFalse(); | ||
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(ints = {63, 65}) | ||
| @DisplayName("해시가 64자가 아니면 IllegalArgumentException이고 메시지에 해시를 넣지 않는다") | ||
| void hashLength(int length) { | ||
| String hash = "a".repeat(length); | ||
|
|
||
| assertThatThrownBy(() -> RefreshToken.create(1L, ClientType.WEB, hash, FAMILY_ID, EXPIRES_AT)) | ||
| .isInstanceOf(IllegalArgumentException.class) | ||
| .message().doesNotContain(hash); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("계보 ID가 36자가 아니거나 필수 값이 없으면 IllegalArgumentException") | ||
| void missingValues() { | ||
| assertThatThrownBy(() -> RefreshToken.create(1L, ClientType.WEB, HASH, "short", EXPIRES_AT)) | ||
| .isInstanceOf(IllegalArgumentException.class); | ||
| assertThatThrownBy(() -> RefreshToken.create(null, ClientType.WEB, HASH, FAMILY_ID, EXPIRES_AT)) | ||
| .isInstanceOf(IllegalArgumentException.class); | ||
| assertThatThrownBy(() -> RefreshToken.create(1L, null, HASH, FAMILY_ID, EXPIRES_AT)) | ||
| .isInstanceOf(IllegalArgumentException.class); | ||
| assertThatThrownBy(() -> RefreshToken.create(1L, ClientType.WEB, HASH, FAMILY_ID, null)) | ||
| .isInstanceOf(IllegalArgumentException.class); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("두 번 폐기해도 처음 폐기한 시각이 남는다") | ||
| void revokeOnce() { | ||
| RefreshToken token = RefreshToken.create(1L, ClientType.WEB, HASH, FAMILY_ID, EXPIRES_AT); | ||
| LocalDateTime first = LocalDateTime.of(2026, 10, 30, 9, 0); | ||
|
|
||
| token.revoke(first); | ||
| token.revoke(first.plusHours(1)); | ||
|
|
||
| assertThat(token.isRevoked()).isTrue(); | ||
| assertThat(token.getRevokedAt()).isEqualTo(first); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("만료 시각 1마이크로초 전에는 유효하고, 만료 시각부터는 만료다") | ||
| void expiryBoundary() { | ||
| RefreshToken token = RefreshToken.create(1L, ClientType.WEB, HASH, FAMILY_ID, EXPIRES_AT); | ||
|
|
||
| assertThat(token.isExpired(EXPIRES_AT.minusNanos(1_000))).isFalse(); | ||
| assertThat(token.isExpired(EXPIRES_AT)).isTrue(); | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| package com.sniptext.domain.auth.repository; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
| import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| import static com.sniptext.fixture.RefreshTokenFixture.hash; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.dao.DataIntegrityViolationException; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import com.sniptext.domain.auth.entity.RefreshToken; | ||
| import com.sniptext.domain.user.repository.UserRepository; | ||
| import com.sniptext.fixture.RefreshTokenFixture; | ||
| import com.sniptext.fixture.UserFixture; | ||
| import com.sniptext.support.IntegrationTestSupport; | ||
|
|
||
| @Transactional | ||
| class RefreshTokenRepositoryTest extends IntegrationTestSupport { | ||
|
|
||
| private static final String FAMILY_A = "aaaaaaaa-0000-0000-0000-000000000001"; | ||
| private static final String FAMILY_B = "bbbbbbbb-0000-0000-0000-000000000002"; | ||
| private static final LocalDateTime NOW = LocalDateTime.of(2026, 10, 30, 9, 0); | ||
|
|
||
| @Autowired | ||
| private RefreshTokenRepository refreshTokenRepository; | ||
|
|
||
| @Autowired | ||
| private UserRepository userRepository; | ||
|
|
||
| private Long userId; | ||
| private Long otherUserId; | ||
|
|
||
| @BeforeEach | ||
| void setUp() { | ||
| userId = userRepository.save(UserFixture.user("token@gmail.com")).getId(); | ||
| otherUserId = userRepository.save(UserFixture.user("other@gmail.com")).getId(); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("해시로 재발급 토큰을 찾는다 (CHAR 컬럼 매핑 확인 포함)") | ||
| void findByTokenHash() { | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(userId, hash(1))); | ||
|
|
||
| assertThat(refreshTokenRepository.findByTokenHash(hash(1))) | ||
| .map(RefreshToken::getFamilyId) | ||
| .contains(RefreshTokenFixture.FAMILY_ID); | ||
| assertThat(refreshTokenRepository.findByTokenHash(hash(2))).isEmpty(); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("같은 해시를 두 번 저장하면 DB 유니크 위반이다") | ||
| void uniqueTokenHash() { | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(userId, hash(1))); | ||
|
|
||
| assertThatThrownBy(() -> refreshTokenRepository.saveAndFlush(RefreshTokenFixture.web(otherUserId, hash(1)))) | ||
| .isInstanceOf(DataIntegrityViolationException.class); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("계보 폐기는 그 계보의 살아 있는 토큰만 폐기하고, 이미 폐기된 토큰의 시각은 그대로 둔다") | ||
| void revokeFamily() { | ||
| RefreshToken alreadyRevoked = RefreshTokenFixture.web(userId, hash(1), FAMILY_A); | ||
| alreadyRevoked.revoke(NOW.minusDays(1)); | ||
| refreshTokenRepository.save(alreadyRevoked); | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(userId, hash(2), FAMILY_A)); | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(userId, hash(3), FAMILY_B)); | ||
|
|
||
| int revoked = refreshTokenRepository.revokeFamily(FAMILY_A, NOW); | ||
|
|
||
| assertThat(revoked).isEqualTo(1); | ||
| assertThat(revokedAt(hash(1))).isEqualTo(NOW.minusDays(1)); | ||
| assertThat(revokedAt(hash(2))).isEqualTo(NOW); | ||
| assertThat(revokedAt(hash(3))).isNull(); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("로그아웃 폐기는 그 회원의 웹·확장 토큰을 모두 폐기하고 다른 회원 토큰은 건드리지 않는다") | ||
| void revokeAllByUserId() { | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(userId, hash(1), FAMILY_A)); | ||
| refreshTokenRepository.save(RefreshTokenFixture.extension(userId, hash(2), FAMILY_B)); | ||
| refreshTokenRepository.save(RefreshTokenFixture.web(otherUserId, hash(3), RefreshTokenFixture.FAMILY_ID)); | ||
|
|
||
| int revoked = refreshTokenRepository.revokeAllByUserId(userId, NOW); | ||
|
|
||
| assertThat(revoked).isEqualTo(2); | ||
| assertThat(revokedAt(hash(1))).isEqualTo(NOW); | ||
| assertThat(revokedAt(hash(2))).isEqualTo(NOW); | ||
| assertThat(revokedAt(hash(3))).isNull(); | ||
| } | ||
|
|
||
| private LocalDateTime revokedAt(String tokenHash) { | ||
| return refreshTokenRepository.findByTokenHash(tokenHash).orElseThrow().getRevokedAt(); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.sniptext.fixture; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| import com.sniptext.domain.auth.entity.ClientType; | ||
| import com.sniptext.domain.auth.entity.RefreshToken; | ||
|
|
||
| /** 테스트용 재발급 토큰. 해시와 계보 ID는 길이만 맞춘 가짜 값이다. 생성 인자가 바뀌면 여기만 고친다. */ | ||
| public final class RefreshTokenFixture { | ||
|
|
||
| public static final String FAMILY_ID = "00000000-0000-0000-0000-000000000001"; | ||
| public static final LocalDateTime EXPIRES_AT = LocalDateTime.of(2026, 11, 8, 10, 0); | ||
|
|
||
| private RefreshTokenFixture() { | ||
| } | ||
|
|
||
| /** n번째 가짜 해시 (16진수 64자) */ | ||
| public static String hash(int n) { | ||
| return String.format("%064x", n); | ||
| } | ||
|
|
||
| public static RefreshToken web(Long userId, String tokenHash) { | ||
| return web(userId, tokenHash, FAMILY_ID); | ||
| } | ||
|
|
||
| public static RefreshToken web(Long userId, String tokenHash, String familyId) { | ||
| return RefreshToken.create(userId, ClientType.WEB, tokenHash, familyId, EXPIRES_AT); | ||
| } | ||
|
|
||
| public static RefreshToken extension(Long userId, String tokenHash, String familyId) { | ||
| return RefreshToken.create(userId, ClientType.EXTENSION, tokenHash, familyId, EXPIRES_AT); | ||
| } | ||
|
|
||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
사소한 건데,
revoke()는 시각이null이면 규칙 위반으로 막는데isExpired()는 검사 없이 바로NullPointerException이 나요. 맞춰 두면 실수했을 때 메시지가 더 분명할 것 같습니다! 수정은 선택사항입니다!