Skip to content
Merged
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
10 changes: 10 additions & 0 deletions src/main/java/com/sniptext/domain/auth/entity/ClientType.java
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

}
114 changes: 114 additions & 0 deletions src/main/java/com/sniptext/domain/auth/entity/RefreshToken.java
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소한 건데, revoke()는 시각이 null이면 규칙 위반으로 막는데 isExpired()는 검사 없이 바로 NullPointerException이 나요. 맞춰 두면 실수했을 때 메시지가 더 분명할 것 같습니다! 수정은 선택사항입니다!

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

재발급 서비스에서 이 메서드로 토큰을 찾고 revoke()로 폐기한 뒤 새 토큰을 저장하는 흐름이 될 것 같은데, 같은 토큰으로 요청 두 개가 동시에 오면 둘 다 "폐기 안 됨"을 읽어서 둘 다 새 토큰을 받게 됩니다. 탈취된 토큰을 공격자와 사용자가 거의 동시에 쓰면 재사용 감지가 안 걸리는 거라, 계보 폐기 설계가 막으려는 상황이 그대로 뚫립니다.

둘 중 하나로 막으면 될 것 같습니다!

  • 이 조회에 @Lock(LockModeType.PESSIMISTIC_WRITE)를 붙인 재발급 전용 조회를 따로 두기
  • update RefreshToken t set t.revokedAt = :now where t.id = :id and t.revokedAt is null 조건부 갱신을 두고, 반영 건수가 0이면 재사용으로 처리

이번 PR에 같이 넣어도 되고, 재발급 PR에서 넣어도 괜찮습니다. 어느 쪽으로 할지만 정해 주세요!


/**
* 계보 전체를 폐기한다 (이미 쓴 토큰의 재사용 감지). 이미 폐기된 토큰은 처음 폐기 시각을 그대로 둔다.
* refresh_tokens에는 updated_at이 없어 직접 넣을 시각 컬럼이 따로 없다.
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearAutomatically = true라서 이 메서드를 부른 뒤에는 같은 트랜잭션에서 미리 읽어 둔 RefreshToken이 영속 상태에서 빠지는 점만 재발급 서비스 만들 때 참고해 주세요. 계보 폐기 후 그 엔티티를 고쳐도 반영이 안 됩니다. (설정 자체는 맞다고 생각해요!)

@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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

픽스처에 같은 값(RefreshTokenFixture.EXPIRES_AT)이 있어서 그걸 쓰면 될 것 같아요. 바로 위 HASH, FAMILY_ID처럼요! 선택사항입니다~


@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();
}

}
34 changes: 34 additions & 0 deletions src/test/java/com/sniptext/fixture/RefreshTokenFixture.java
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);
}

}
Loading