diff --git a/src/main/java/com/sniptext/domain/auth/entity/ClientType.java b/src/main/java/com/sniptext/domain/auth/entity/ClientType.java new file mode 100644 index 0000000..ec40e54 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/entity/ClientType.java @@ -0,0 +1,10 @@ +package com.sniptext.domain.auth.entity; + +/** 재발급 토큰을 받은 곳. 로그아웃은 둘 다 폐기한다 (명세 4번). 확장 전용 발급 방식은 S2에서 정한다. */ +public enum ClientType { + + WEB, + + EXTENSION + +} diff --git a/src/main/java/com/sniptext/domain/auth/entity/RefreshToken.java b/src/main/java/com/sniptext/domain/auth/entity/RefreshToken.java new file mode 100644 index 0000000..1bd6568 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/entity/RefreshToken.java @@ -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); + } + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/sniptext/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..b32e76f --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/repository/RefreshTokenRepository.java @@ -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 { + + Optional findByTokenHash(String tokenHash); + + /** + * 계보 전체를 폐기한다 (이미 쓴 토큰의 재사용 감지). 이미 폐기된 토큰은 처음 폐기 시각을 그대로 둔다. + * refresh_tokens에는 updated_at이 없어 직접 넣을 시각 컬럼이 따로 없다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @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); + +} diff --git a/src/test/java/com/sniptext/domain/auth/entity/RefreshTokenTest.java b/src/test/java/com/sniptext/domain/auth/entity/RefreshTokenTest.java new file mode 100644 index 0000000..d1b4326 --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/entity/RefreshTokenTest.java @@ -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); + + @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(); + } + +} diff --git a/src/test/java/com/sniptext/domain/auth/repository/RefreshTokenRepositoryTest.java b/src/test/java/com/sniptext/domain/auth/repository/RefreshTokenRepositoryTest.java new file mode 100644 index 0000000..13298f6 --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/repository/RefreshTokenRepositoryTest.java @@ -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(); + } + +} diff --git a/src/test/java/com/sniptext/fixture/RefreshTokenFixture.java b/src/test/java/com/sniptext/fixture/RefreshTokenFixture.java new file mode 100644 index 0000000..f6f2e18 --- /dev/null +++ b/src/test/java/com/sniptext/fixture/RefreshTokenFixture.java @@ -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); + } + +}