From 8c40eeadc5149c0a5b9d60f97b879ab0fa511b4c Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:34:47 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat:=20RefreshToken=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20Redis=EC=97=90=EC=84=9C=20MySQL=EB=A1=9C?= =?UTF-8?q?=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshToken 엔티티에 token rotation 관련 필드 추가 - TokenStatus enum 추가 (ACTIVE, REVOKED, EXPIRED, ROTATED) - 원자적 업데이트를 위한 markAsRotatedIfActive 메서드 추가 - QueryDSL 기반 커스텀 Repository 구현 (revoke, hard delete) - 새로운 에러 코드 추가 (_TOKEN_REUSE_DETECTED, _DUPLICATED_REQUEST, _ALREADY_USED_REFRESH_TOKEN) Co-Authored-By: Claude Opus 4.5 --- .../side/onetime/domain/RefreshToken.java | 158 +- .../onetime/domain/enums/TokenStatus.java | 13 + .../exception/status/TokenErrorStatus.java | 7 +- .../repository/RefreshTokenRepository.java | 123 +- .../custom/RefreshTokenRepositoryCustom.java | 44 + .../custom/RefreshTokenRepositoryImpl.java | 61 + .../resources/static/docs/open-api-3.0.1.json | 2921 ----------------- 7 files changed, 317 insertions(+), 3010 deletions(-) create mode 100644 src/main/java/side/onetime/domain/enums/TokenStatus.java create mode 100644 src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryCustom.java create mode 100644 src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java delete mode 100644 src/main/resources/static/docs/open-api-3.0.1.json diff --git a/src/main/java/side/onetime/domain/RefreshToken.java b/src/main/java/side/onetime/domain/RefreshToken.java index 5a07f1cb..2761bbee 100644 --- a/src/main/java/side/onetime/domain/RefreshToken.java +++ b/src/main/java/side/onetime/domain/RefreshToken.java @@ -1,18 +1,164 @@ package side.onetime.domain; +import java.time.LocalDateTime; +import java.util.UUID; + +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.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; +import side.onetime.domain.enums.TokenStatus; +import side.onetime.global.common.dao.BaseEntity; +/** + * Refresh Token 엔티티 + * + * Token Rotation 추적 및 사용 이력 로깅을 위한 테이블 + * - family_id: 로그인 세션 단위로 토큰 패밀리 관리 (UUID) + * - jti: JWT 고유 식별자 (조회 키) + * - status: 토큰 상태 (ACTIVE, REVOKED, EXPIRED, ROTATED) + * - Hard Delete: 오래된 비활성 토큰은 물리적으로 삭제 + */ +@Entity +@Table(name = "refresh_token", indexes = { + @Index(name = "idx_refresh_token_family", columnList = "family_id"), + @Index(name = "idx_refresh_token_user_browser", columnList = "users_id, browser_id"), + @Index(name = "idx_refresh_token_expiry", columnList = "expiry_at") +}) @Getter -public class RefreshToken { +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "family_id", nullable = false, length = 36) + private String familyId; + + @Column(name = "users_id", nullable = false) private Long userId; + + @Column(nullable = false, unique = true, length = 128) + private String jti; + + @Column(name = "browser_id", nullable = false, length = 256) private String browserId; - private String refreshToken; - public RefreshToken(Long userId, String browserId, String refreshToken) { - this.userId = userId; - this.browserId = browserId; - this.refreshToken = refreshToken; + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "user_ip", length = 45) + private String userIp; + + @Column(name = "token_value", nullable = false, columnDefinition = "TEXT") + private String tokenValue; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TokenStatus status; + + @Column(name = "issued_at", nullable = false) + private LocalDateTime issuedAt; + + @Column(name = "expiry_at", nullable = false) + private LocalDateTime expiryAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "last_used_ip", length = 45) + private String lastUsedIp; + + @Column(name = "reissue_count", nullable = false) + private int reissueCount; + + /** + * 신규 Refresh Token 생성 (로그인 시) + * + * @param userId 사용자 ID + * @param jti JWT 고유 식별자 + * @param browserId 브라우저 식별자 (User-Agent 해시) + * @param tokenValue Refresh Token JWT 문자열 + * @param issuedAt 발급 시각 + * @param expiryAt 만료 시각 + * @param userIp 발급 시 IP + * @param userAgent 발급 시 User-Agent + * @return 새로 생성된 RefreshToken 엔티티 + */ + public static RefreshToken create(Long userId, String jti, String browserId, + String tokenValue, LocalDateTime issuedAt, + LocalDateTime expiryAt, String userIp, + String userAgent) { + RefreshToken token = new RefreshToken(); + token.familyId = UUID.randomUUID().toString(); + token.userId = userId; + token.jti = jti; + token.browserId = browserId; + token.tokenValue = tokenValue; + token.status = TokenStatus.ACTIVE; + token.issuedAt = issuedAt; + token.expiryAt = expiryAt; + token.userIp = userIp; + token.userAgent = userAgent; + token.reissueCount = 0; + return token; + } + + /** + * Token Rotation으로 새 토큰 생성 (재발급 시) + * + * @param newJti 새 JWT 고유 식별자 + * @param newTokenValue 새 Refresh Token JWT 문자열 + * @param newIssuedAt 새 발급 시각 + * @param newExpiryAt 새 만료 시각 + * @param newUserIp 새 발급 시 IP + * @param newUserAgent 새 발급 시 User-Agent + * @return 로테이션된 새 RefreshToken 엔티티 (같은 family_id 유지) + */ + public RefreshToken rotate(String newJti, String newTokenValue, + LocalDateTime newIssuedAt, LocalDateTime newExpiryAt, + String newUserIp, String newUserAgent) { + RefreshToken token = new RefreshToken(); + token.familyId = this.familyId; + token.userId = this.userId; + token.jti = newJti; + token.browserId = this.browserId; + token.tokenValue = newTokenValue; + token.status = TokenStatus.ACTIVE; + token.issuedAt = newIssuedAt; + token.expiryAt = newExpiryAt; + token.userIp = newUserIp; + token.userAgent = newUserAgent; + token.reissueCount = this.reissueCount + 1; + return token; + } + + /** + * 토큰 상태를 ROTATED로 변경 (Token Rotation 시) + * + * @param lastUsedIp 마지막 사용 IP + */ + public void markAsRotated(String lastUsedIp) { + this.status = TokenStatus.ROTATED; + this.lastUsedAt = LocalDateTime.now(); + this.lastUsedIp = lastUsedIp; + } + + /** + * 토큰이 활성 상태인지 확인 + * + * @return 활성 상태 여부 + */ + public boolean isActive() { + return this.status == TokenStatus.ACTIVE; } } diff --git a/src/main/java/side/onetime/domain/enums/TokenStatus.java b/src/main/java/side/onetime/domain/enums/TokenStatus.java new file mode 100644 index 00000000..a66ea974 --- /dev/null +++ b/src/main/java/side/onetime/domain/enums/TokenStatus.java @@ -0,0 +1,13 @@ +package side.onetime.domain.enums; + +/** + * Refresh Token 상태 enum + * + * Token Rotation 및 토큰 라이프사이클 관리를 위한 상태 정의 + */ +public enum TokenStatus { + ACTIVE, // 활성 (사용 가능) + REVOKED, // 폐기 (로그아웃, 강제 만료) + EXPIRED, // 만료 (자연 만료) + ROTATED // 로테이션 (Token Rotation으로 새 토큰 발급됨) +} diff --git a/src/main/java/side/onetime/exception/status/TokenErrorStatus.java b/src/main/java/side/onetime/exception/status/TokenErrorStatus.java index c5e87544..c892199c 100644 --- a/src/main/java/side/onetime/exception/status/TokenErrorStatus.java +++ b/src/main/java/side/onetime/exception/status/TokenErrorStatus.java @@ -1,8 +1,9 @@ package side.onetime.exception.status; +import org.springframework.http.HttpStatus; + import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import side.onetime.global.common.code.BaseErrorCode; import side.onetime.global.common.dto.ErrorReasonDto; @@ -18,6 +19,10 @@ public enum TokenErrorStatus implements BaseErrorCode { _INVALID_USER_TYPE(HttpStatus.BAD_REQUEST, "TOKEN-007", "알 수 없는 타입의 액세스 토큰이 발행되었습니다."), _NOT_FOUND_HEADER(HttpStatus.BAD_REQUEST, "TOKEN-008", "Authorization 헤더가 존재하지 않거나 형식이 잘못되었습니다."), _TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "TOKEN-009", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + _INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN-010", "유효하지 않은 리프레시 토큰입니다."), + _TOKEN_REUSE_DETECTED(HttpStatus.UNAUTHORIZED, "TOKEN-011", "토큰 재사용이 감지되었습니다. 보안을 위해 다시 로그인해주세요."), + _DUPLICATED_REQUEST(HttpStatus.TOO_MANY_REQUESTS, "TOKEN-012", "중복 요청입니다. 잠시 후 다시 시도해주세요."), + _ALREADY_USED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN-013", "이미 사용된 리프레시 토큰입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/side/onetime/repository/RefreshTokenRepository.java b/src/main/java/side/onetime/repository/RefreshTokenRepository.java index 5fb5dbb4..a3e71cd0 100644 --- a/src/main/java/side/onetime/repository/RefreshTokenRepository.java +++ b/src/main/java/side/onetime/repository/RefreshTokenRepository.java @@ -1,88 +1,47 @@ package side.onetime.repository; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; -import side.onetime.domain.RefreshToken; - -import java.util.List; +import java.time.LocalDateTime; import java.util.Optional; -import java.util.concurrent.TimeUnit; - -@Repository -@RequiredArgsConstructor -public class RefreshTokenRepository { - - @Value("${jwt.refresh-token.expiration-time}") - private long REFRESH_TOKEN_EXPIRATION_TIME; - - private static final int REFRESH_TOKEN_LIMIT = 5; - private static final String COOLDOWN_PREFIX = "cooldown:reissue:"; - - private final RedisTemplate redisTemplate; - - public void save(RefreshToken refreshToken) { - String key = "refreshToken:" + refreshToken.getUserId(); - String value = refreshToken.getBrowserId() + ":" + refreshToken.getRefreshToken(); - - List existing = redisTemplate.opsForList().range(key, 0, -1); - - if (existing != null) { - // 기존 토큰 제거 - existing.removeIf(token -> token.startsWith(refreshToken.getBrowserId() + ":")); - redisTemplate.delete(key); - for (String item : existing) { - redisTemplate.opsForList().rightPush(key, item); - } - } - - // 최신 토큰 맨 앞에 추가 - redisTemplate.opsForList().leftPush(key, value); - redisTemplate.opsForList().trim(key, 0, REFRESH_TOKEN_LIMIT - 1); - redisTemplate.expire(key, REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS); - } - public Optional findByUserIdAndBrowserId(Long userId, String browserId) { - String key = "refreshToken:" + userId; - List tokens = redisTemplate.opsForList().range(key, 0, -1); +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; - if (tokens == null) return Optional.empty(); - - return tokens.stream() - .filter(t -> t.startsWith(browserId + ":")) - .findFirst() - .map(t -> t.substring(browserId.length() + 1)); - } - - public boolean isInCooldown(Long userId, String browserId) { - String key = COOLDOWN_PREFIX + userId + ":" + browserId; - return Boolean.TRUE.equals(redisTemplate.hasKey(key)); - } - - public void setCooldown(Long userId, String browserId, long millis) { - String key = COOLDOWN_PREFIX + userId + ":" + browserId; - redisTemplate.opsForValue().set(key, "1", millis, TimeUnit.MILLISECONDS); - } - - public void deleteAllByUserId(Long userId) { - String pattern = "refreshToken:" + userId; - redisTemplate.delete(pattern); - } - - public void deleteRefreshToken(Long userId, String browserId) { - String key = "refreshToken:" + userId; - - List tokens = redisTemplate.opsForList().range(key, 0, -1); - if (tokens == null || tokens.isEmpty()) return; - - tokens.removeIf(token -> token.startsWith(browserId + ":")); - - redisTemplate.delete(key); - for (String token : tokens) { - redisTemplate.opsForList().rightPush(key, token); - } - - redisTemplate.expire(key, REFRESH_TOKEN_EXPIRATION_TIME, TimeUnit.MILLISECONDS); - } +import side.onetime.domain.RefreshToken; +import side.onetime.repository.custom.RefreshTokenRepositoryCustom; + +public interface RefreshTokenRepository extends JpaRepository, RefreshTokenRepositoryCustom { + + /** + * jti(JWT ID)로 RefreshToken 조회 + * + * @param jti JWT 고유 식별자 + * @return RefreshToken + */ + Optional findByJti(String jti); + + /** + * 원자적 업데이트: ACTIVE 상태인 경우에만 ROTATED로 변경 + * Race condition 방지를 위해 WHERE 절에서 상태 체크 + * updatedDate도 함께 업데이트 (JPA auditing이 bulk 쿼리에서 동작하지 않음) + * + * @param tokenId 토큰 ID + * @param lastUsedAt 마지막 사용 시각 + * @param lastUsedIp 마지막 사용 IP + * @return 업데이트된 행 수 (0이면 이미 rotate됨) + */ + @Modifying + @Query(""" + UPDATE RefreshToken r + SET r.status = 'ROTATED', + r.lastUsedAt = :lastUsedAt, + r.lastUsedIp = :lastUsedIp, + r.updatedDate = :lastUsedAt + WHERE r.id = :tokenId + AND r.status = 'ACTIVE' + """) + int markAsRotatedIfActive(@Param("tokenId") Long tokenId, + @Param("lastUsedAt") LocalDateTime lastUsedAt, + @Param("lastUsedIp") String lastUsedIp); } diff --git a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryCustom.java b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryCustom.java new file mode 100644 index 00000000..ee0c5cc2 --- /dev/null +++ b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryCustom.java @@ -0,0 +1,44 @@ +package side.onetime.repository.custom; + +import java.time.LocalDateTime; + +public interface RefreshTokenRepositoryCustom { + + /** + * 특정 사용자 + 브라우저의 ACTIVE 토큰을 REVOKED로 변경 (로그아웃) + * + * @param userId 사용자 ID + * @param browserId 브라우저 식별자 + */ + void revokeByUserIdAndBrowserId(Long userId, String browserId); + + /** + * 특정 사용자의 모든 ACTIVE 토큰을 REVOKED로 변경 (전체 로그아웃, 탈퇴) + * + * @param userId 사용자 ID + */ + void revokeAllByUserId(Long userId); + + /** + * 특정 토큰 패밀리의 모든 ACTIVE/ROTATED 토큰을 REVOKED로 변경 (공격 탐지 시) + * + * @param familyId 토큰 패밀리 ID + */ + void revokeAllByFamilyId(String familyId); + + /** + * 만료된 ACTIVE 토큰을 EXPIRED로 변경 + * + * @param now 현재 시각 + * @return 변경된 토큰 수 + */ + int updateExpiredTokens(LocalDateTime now); + + /** + * 오래된 비활성 토큰을 삭제 (Hard Delete) + * + * @param threshold 기준 시각 (이 시각 이전에 수정된 토큰 대상) + * @return 삭제된 토큰 수 + */ + int hardDeleteOldInactiveTokens(LocalDateTime threshold); +} diff --git a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java new file mode 100644 index 00000000..07d2f09a --- /dev/null +++ b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java @@ -0,0 +1,61 @@ +package side.onetime.repository.custom; + +import static side.onetime.domain.QRefreshToken.*; + +import java.time.LocalDateTime; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import side.onetime.domain.enums.TokenStatus; + +@RequiredArgsConstructor +public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public void revokeByUserIdAndBrowserId(Long userId, String browserId) { + queryFactory.update(refreshToken) + .set(refreshToken.status, TokenStatus.REVOKED) + .where(refreshToken.userId.eq(userId) + .and(refreshToken.browserId.eq(browserId)) + .and(refreshToken.status.eq(TokenStatus.ACTIVE))) + .execute(); + } + + @Override + public void revokeAllByUserId(Long userId) { + queryFactory.update(refreshToken) + .set(refreshToken.status, TokenStatus.REVOKED) + .where(refreshToken.userId.eq(userId) + .and(refreshToken.status.eq(TokenStatus.ACTIVE))) + .execute(); + } + + @Override + public void revokeAllByFamilyId(String familyId) { + queryFactory.update(refreshToken) + .set(refreshToken.status, TokenStatus.REVOKED) + .where(refreshToken.familyId.eq(familyId) + .and(refreshToken.status.in(TokenStatus.ACTIVE, TokenStatus.ROTATED))) + .execute(); + } + + @Override + public int updateExpiredTokens(LocalDateTime now) { + return (int) queryFactory.update(refreshToken) + .set(refreshToken.status, TokenStatus.EXPIRED) + .where(refreshToken.status.eq(TokenStatus.ACTIVE) + .and(refreshToken.expiryAt.lt(now))) + .execute(); + } + + @Override + public int hardDeleteOldInactiveTokens(LocalDateTime threshold) { + return (int) queryFactory.delete(refreshToken) + .where(refreshToken.status.in(TokenStatus.REVOKED, TokenStatus.EXPIRED, TokenStatus.ROTATED) + .and(refreshToken.updatedDate.lt(threshold))) + .execute(); + } +} diff --git a/src/main/resources/static/docs/open-api-3.0.1.json b/src/main/resources/static/docs/open-api-3.0.1.json deleted file mode 100644 index c7ea1cbd..00000000 --- a/src/main/resources/static/docs/open-api-3.0.1.json +++ /dev/null @@ -1,2921 +0,0 @@ -{ - "openapi" : "3.0.1", - "info" : { - "title" : "OneTime API Documentation", - "description" : "Spring REST Docs with Swagger UI.", - "version" : "0.0.1" - }, - "servers" : [ { - "url" : "https://onetime-test.store" - } ], - "tags" : [ ], - "paths" : { - "/api/v1/events" : { - "post" : { - "tags" : [ "Event API" ], - "summary" : "이벤트를 생성한다.(토큰 유무에 따라 로그인/비로그인 구분)", - "description" : "이벤트를 생성한다.(토큰 유무에 따라 로그인/비로그인 구분)", - "operationId" : "event/create", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/CreateEventRequestSchema" - }, - "examples" : { - "event/create" : { - "value" : "{\n \"title\" : \"Sample Event\",\n \"start_time\" : \"10:00\",\n \"end_time\" : \"12:00\",\n \"category\" : \"DATE\",\n \"ranges\" : [ \"2024.11.13\" ]\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/CreateEventResponseSchema" - }, - "examples" : { - "event/create" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"이벤트 생성에 성공했습니다.\",\n \"payload\" : {\n \"event_id\" : \"cc9eb53a-179c-42bf-8a90-0ad89761536e\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/fixed-schedules" : { - "get" : { - "tags" : [ "Fixed API" ], - "summary" : "전체 고정 스케줄을 조회한다.", - "description" : "전체 고정 스케줄을 조회한다.", - "operationId" : "fixed/getAll", - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-1646561338" - }, - "examples" : { - "fixed/getAll" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"전체 고정 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"id\" : 1,\n \"schedules\" : [ {\n \"time_point\" : \"월\",\n \"times\" : [ \"09:00\", \"09:30\" ]\n } ]\n }, {\n \"id\" : 2,\n \"schedules\" : [ {\n \"time_point\" : \"화\",\n \"times\" : [ \"10:00\", \"10:30\" ]\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "post" : { - "tags" : [ "Fixed API" ], - "summary" : "고정 스케줄을 등록한다.", - "description" : "고정 스케줄을 등록한다.", - "operationId" : "fixed/create", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-1734125469" - }, - "examples" : { - "fixed/create" : { - "value" : "{\n \"title\" : \"고정 이벤트\",\n \"schedules\" : [ {\n \"time_point\" : \"월\",\n \"times\" : [ \"09:00\", \"09:30\" ]\n } ]\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-id-1529947151" - }, - "examples" : { - "fixed/create" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"고정 스케줄 등록에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/events/{event_id}" : { - "get" : { - "tags" : [ "Event API" ], - "summary" : "이벤트를 조회한다.", - "description" : "이벤트를 조회한다.", - "operationId" : "event/get", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "조회할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetEventResponseSchema" - }, - "examples" : { - "event/get" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"이벤트 조회에 성공했습니다.\",\n \"payload\" : {\n \"event_id\" : \"1f4c4558-97f8-49c1-aec4-f681173534d0\",\n \"title\" : \"Sample Event\",\n \"start_time\" : \"10:00\",\n \"end_time\" : \"12:00\",\n \"category\" : \"DATE\",\n \"ranges\" : [ \"2024.11.13\" ],\n \"event_status\" : \"CREATOR\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "delete" : { - "tags" : [ "Event API" ], - "summary" : "유저가 생성한 이벤트를 삭제한다.", - "description" : "유저가 생성한 이벤트를 삭제한다.", - "operationId" : "event/remove-user-created-event", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "삭제할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/RemoveUserCreatedEventResponseSchema" - }, - "examples" : { - "event/remove-user-created-event" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저가 생성한 이벤트 삭제에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "patch" : { - "tags" : [ "Event API" ], - "summary" : "유저가 생성한 이벤트를 수정한다.", - "description" : "유저가 생성한 이벤트를 수정한다.", - "operationId" : "event/modify-user-created-event-title", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "수정할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-events-event_id1666935626" - }, - "examples" : { - "event/modify-user-created-event-title" : { - "value" : "{\n \"title\" : \"수정된 이벤트 제목\",\n \"start_time\" : \"09:00\",\n \"end_time\" : \"18:00\",\n \"ranges\" : [ \"2024.12.10\", \"2024.12.11\" ]\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/ModifyUserCreatedEventTitleResponseSchema" - }, - "examples" : { - "event/modify-user-created-event-title" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저가 생성한 이벤트 수정에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/fixed-schedules/{id}" : { - "get" : { - "tags" : [ "Fixed API" ], - "summary" : "특정 고정 스케줄 상세 조회한다.", - "description" : "특정 고정 스케줄 상세 조회한다.", - "operationId" : "fixed/getDetail", - "parameters" : [ { - "name" : "id", - "in" : "path", - "description" : "고정 스케줄 ID [예시 : 1]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-id-1244436439" - }, - "examples" : { - "fixed/getDetail" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"특정 고정 스케줄 상세 조회에 성공했습니다.\",\n \"payload\" : {\n \"title\" : \"고정 이벤트\",\n \"schedules\" : [ {\n \"time_point\" : \"월\",\n \"times\" : [ \"09:00\", \"09:30\" ]\n } ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "delete" : { - "tags" : [ "Fixed API" ], - "summary" : "특정 고정 스케줄을 삭제한다.", - "description" : "특정 고정 스케줄을 삭제한다.", - "operationId" : "fixed/delete", - "parameters" : [ { - "name" : "id", - "in" : "path", - "description" : "고정 스케줄 ID [예시 : 1]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-id-1529947151" - }, - "examples" : { - "fixed/delete" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"고정 스케줄 삭제에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "patch" : { - "tags" : [ "Fixed API" ], - "summary" : "특정 고정 스케줄을 수정한다.", - "description" : "특정 고정 스케줄을 수정한다.", - "operationId" : "fixed/modify", - "parameters" : [ { - "name" : "id", - "in" : "path", - "description" : "고정 스케줄 ID [예시 : 1]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-id507597251" - }, - "examples" : { - "fixed/modify" : { - "value" : "{\n \"title\" : \"수정된 고정 스케줄\",\n \"schedules\" : [ {\n \"time_point\" : \"화\",\n \"times\" : [ \"10:00\", \"11:00\" ]\n } ]\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-id-1529947151" - }, - "examples" : { - "fixed/modify" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"고정 스케줄 수정에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/members/action-login" : { - "post" : { - "tags" : [ "Member API" ], - "summary" : "멤버 로그인을 진행한다.", - "description" : "멤버 로그인을 진행한다.", - "operationId" : "member/login", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-action-login-1923712789" - }, - "examples" : { - "member/login" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"name\" : \"existingMember\",\n \"pin\" : \"1234\"\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-action-register1632683266" - }, - "examples" : { - "member/login" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"멤버 로그인에 성공했습니다.\",\n \"payload\" : {\n \"member_id\" : \"789e0123-e45b-67c8-d901-234567890abc\",\n \"category\" : \"DATE\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/members/action-register" : { - "post" : { - "tags" : [ "Member API" ], - "summary" : "멤버를 등록한다.", - "description" : "멤버를 등록한다.", - "operationId" : "member/register", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-action-register946525738" - }, - "examples" : { - "member/register" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"name\" : \"newMember\",\n \"pin\" : \"1234\",\n \"schedules\" : [ {\n \"time_point\" : \"2024.12.01\",\n \"times\" : [ \"09:00\", \"10:00\" ]\n }, {\n \"time_point\" : \"2024.12.02\",\n \"times\" : [ \"11:00\", \"12:00\" ]\n } ]\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-action-register1632683266" - }, - "examples" : { - "member/register" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"멤버 등록에 성공했습니다.\",\n \"payload\" : {\n \"member_id\" : \"789e0123-e45b-67c8-d901-234567890abc\",\n \"category\" : \"CATEGORY\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/date" : { - "post" : { - "tags" : [ "Schedule API" ], - "summary" : "날짜 스케줄을 등록한다. (비로그인의 경우에는 멤버 ID가 필수 값)", - "description" : "날짜 스케줄을 등록한다. (비로그인의 경우에는 멤버 ID가 필수 값)", - "operationId" : "schedule/create-date-authenticated", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-1423118481" - }, - "examples" : { - "schedule/create-date-authenticated" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"member_id\" : \"789e0123-e45b-67c8-d901-234567890abc\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"2024.12.01\"\n } ]\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-action-withdraw710843682" - }, - "examples" : { - "schedule/create-date-authenticated" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"날짜 스케줄 등록에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/day" : { - "post" : { - "tags" : [ "Schedule API" ], - "summary" : "요일 스케줄을 등록한다. (비로그인의 경우에는 멤버 ID가 필수 값)", - "description" : "요일 스케줄을 등록한다. (비로그인의 경우에는 멤버 ID가 필수 값)", - "operationId" : "schedule/create-day-anonymous", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day485021249" - }, - "examples" : { - "schedule/create-day-anonymous" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"member_id\" : \"789e0123-e45b-67c8-d901-234567890abc\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"월\"\n } ]\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-action-withdraw710843682" - }, - "examples" : { - "schedule/create-day-anonymous" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"요일 스케줄 등록에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/tokens/action-reissue" : { - "post" : { - "tags" : [ "Token API" ], - "summary" : "액세스 토큰을 재발행한다.", - "description" : "액세스 토큰을 재발행한다.", - "operationId" : "token/reissue", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-tokens-action-reissue-1852733133" - }, - "examples" : { - "token/reissue" : { - "value" : "{\n \"refresh_token\" : \"sampleOldRefreshToken\"\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-tokens-action-reissue-869168215" - }, - "examples" : { - "token/reissue" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"토큰 재발행에 성공했습니다.\",\n \"payload\" : {\n \"access_token\" : \"newAccessToken\",\n \"refresh_token\" : \"newRefreshToken\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/urls/action-original" : { - "post" : { - "tags" : [ "URL API" ], - "summary" : "단축 URL을 원본 URL로 변환한다.", - "description" : "단축 URL을 원본 URL로 변환한다.", - "operationId" : "url/convert-to-original", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-urls-action-original-209534800" - }, - "examples" : { - "url/convert-to-original" : { - "value" : "{\n \"shorten_url\" : \"https://short.ly/abc123\"\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-urls-action-original-1188642833" - }, - "examples" : { - "url/convert-to-original" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"원본 URL 변환에 성공했습니다.\",\n \"payload\" : {\n \"original_url\" : \"https://example.com/event/123e4567-e89b-12d3-a456-426614174000\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/urls/action-shorten" : { - "post" : { - "tags" : [ "URL API" ], - "summary" : "원본 URL을 단축 URL로 변환한다.", - "description" : "원본 URL을 단축 URL로 변환한다.", - "operationId" : "url/convert-to-shorten", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-urls-action-shorten1745576439" - }, - "examples" : { - "url/convert-to-shorten" : { - "value" : "{\n \"original_url\" : \"https://example.com/event/123e4567-e89b-12d3-a456-426614174000\"\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-urls-action-shorten1006350093" - }, - "examples" : { - "url/convert-to-shorten" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"단축 URL 변환에 성공했습니다.\",\n \"payload\" : {\n \"shorten_url\" : \"https://short.ly/abc123\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/users/action-withdraw" : { - "post" : { - "tags" : [ "User API" ], - "summary" : "유저가 서비스를 탈퇴한다.", - "description" : "유저가 서비스를 탈퇴한다.", - "operationId" : "user/withdraw-service", - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-action-withdraw710843682" - }, - "examples" : { - "user/withdraw-service" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 서비스 탈퇴에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/users/onboarding" : { - "post" : { - "tags" : [ "User API" ], - "summary" : "유저 온보딩을 진행한다.", - "description" : "유저 온보딩을 진행한다.", - "operationId" : "user/onboard", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/OnboardUserRequestSchema" - }, - "examples" : { - "user/onboard" : { - "value" : "{\n \"register_token\" : \"sampleRegisterToken\",\n \"nickname\" : \"UserNickname\"\n}" - } - } - } - } - }, - "responses" : { - "201" : { - "description" : "201", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/OnboardUserResponseSchema" - }, - "examples" : { - "user/onboard" : { - "value" : "{\n \"code\" : \"201\",\n \"message\" : \"유저 온보딩에 성공했습니다.\",\n \"payload\" : {\n \"access_token\" : \"sampleAccessToken\",\n \"refresh_token\" : \"sampleRefreshToken\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/users/policy" : { - "get" : { - "tags" : [ "User API" ], - "summary" : "유저 약관 동의 여부를 조회한다.", - "description" : "유저 약관 동의 여부를 조회한다.", - "operationId" : "user/get-policy-agreement", - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetUserPolicyAgreementResponseSchema" - }, - "examples" : { - "user/get-policy-agreement" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 약관 동의 여부 조회에 성공했습니다.\",\n \"payload\" : {\n \"service_policy_agreement\" : true,\n \"privacy_policy_agreement\" : true,\n \"marketing_policy_agreement\" : false\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - }, - "put" : { - "tags" : [ "User API" ], - "summary" : "유저 약관 동의 여부를 수정한다.", - "description" : "유저 약관 동의 여부를 수정한다.", - "operationId" : "user/update-policy-agreement", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/UpdateUserPolicyAgreementRequestSchema" - }, - "examples" : { - "user/update-policy-agreement" : { - "value" : "{\n \"service_policy_agreement\" : true,\n \"privacy_policy_agreement\" : true,\n \"marketing_policy_agreement\" : false\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-action-withdraw710843682" - }, - "examples" : { - "user/update-policy-agreement" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 약관 동의 여부 수정에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/users/profile" : { - "get" : { - "tags" : [ "User API" ], - "summary" : "유저 정보를 조회한다.", - "description" : "유저 정보를 조회한다.", - "operationId" : "user/get-profile", - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetUserProfileResponseSchema" - }, - "examples" : { - "user/get-profile" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 정보 조회에 성공했습니다.\",\n \"payload\" : {\n \"nickname\" : \"UserNickname\",\n \"email\" : \"user@example.com\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/events/qr/{event_id}" : { - "get" : { - "tags" : [ "Event API" ], - "summary" : "이벤트 QR 코드를 조회한다.", - "description" : "이벤트 QR 코드를 조회한다.", - "operationId" : "event/get-event-qr-code", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "조회할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetEventQrCodeResponseSchema" - }, - "examples" : { - "event/get-event-qr-code" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"이벤트 QR 코드 조회에 성공했습니다.\",\n \"payload\" : {\n \"qr_code_img_url\" : \"https://example.com/qr-code-image.png\"\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/events/user/all" : { - "get" : { - "tags" : [ "Event API" ], - "summary" : "유저가 참여한 이벤트 목록을 조회한다.", - "description" : "유저가 참여한 이벤트 목록을 조회한다.", - "operationId" : "event/get-user-participated-events", - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetUserParticipatedEventsResponseSchema" - }, - "examples" : { - "event/get-user-participated-events" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 참여 이벤트 목록 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"event_id\" : \"d461fff1-d765-4302-8e4a-a26e5e261978\",\n \"category\" : \"DATE\",\n \"title\" : \"Sample Event\",\n \"created_date\" : \"2024.11.13\",\n \"participant_count\" : 10,\n \"event_status\" : \"CREATOR\",\n \"most_possible_times\" : [ {\n \"time_point\" : \"2024.11.13\",\n \"start_time\" : \"10:00\",\n \"end_time\" : \"10:30\",\n \"possible_count\" : 5,\n \"possible_names\" : [ \"User1\", \"User2\" ],\n \"impossible_names\" : [ \"User3\" ]\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/events/{event_id}/most" : { - "get" : { - "tags" : [ "Event API" ], - "summary" : "가장 많이 되는 시간을 조회한다.", - "description" : "가장 많이 되는 시간을 조회한다.", - "operationId" : "event/get-most-possible-time", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "조회할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetMostPossibleTimeResponseSchema" - }, - "examples" : { - "event/get-most-possible-time" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"가장 많이 되는 시간 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"time_point\" : \"2024.11.13\",\n \"start_time\" : \"10:00\",\n \"end_time\" : \"10:30\",\n \"possible_count\" : 5,\n \"possible_names\" : [ \"User1\", \"User2\" ],\n \"impossible_names\" : [ \"User3\" ]\n }, {\n \"time_point\" : \"2024.11.13\",\n \"start_time\" : \"11:00\",\n \"end_time\" : \"11:30\",\n \"possible_count\" : 4,\n \"possible_names\" : [ \"User1\", \"User3\" ],\n \"impossible_names\" : [ \"User2\" ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/events/{event_id}/participants" : { - "get" : { - "tags" : [ "Event API" ], - "summary" : "이벤트 참여자 목록을 조회한다.", - "description" : "이벤트 참여자 목록을 조회한다.", - "operationId" : "event/get-participants", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "조회할 이벤트의 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/GetParticipantsResponseSchema" - }, - "examples" : { - "event/get-participants" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"참여자 조회에 성공했습니다.\",\n \"payload\" : {\n \"names\" : [ \"Member1\", \"User1\", \"Member2\", \"User2\" ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/fixed-schedules/by-day/{day}" : { - "get" : { - "tags" : [ "Fixed API" ], - "summary" : "요일별 고정 스케줄을 조회한다.", - "description" : "요일별 고정 스케줄을 조회한다.", - "operationId" : "fixed/getByDay", - "parameters" : [ { - "name" : "day", - "in" : "path", - "description" : "조회할 요일 [예시 : mon, tue, ...]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-fixed-schedules-by-day-day986652941" - }, - "examples" : { - "fixed/getByDay" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"요일 별 고정 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"id\" : 1,\n \"title\" : \"고정 이벤트\",\n \"start_time\" : \"09:00\",\n \"end_time\" : \"10:00\"\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/members/name/action-check" : { - "post" : { - "tags" : [ "Member API" ], - "summary" : "멤버 이름 중복 확인을 진행한다.", - "description" : "멤버 이름 중복 확인을 진행한다.", - "operationId" : "member/check-duplicate", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-name-action-check-1509950010" - }, - "examples" : { - "member/check-duplicate" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"name\" : \"duplicateCheckName\"\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-members-name-action-check-143782741" - }, - "examples" : { - "member/check-duplicate" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"멤버 이름 중복 확인에 성공했습니다.\",\n \"payload\" : {\n \"is_possible\" : true\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/date/action-filtering" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "멤버 필터링 날짜 스케줄을 조회한다.", - "description" : "멤버 필터링 날짜 스케줄을 조회한다.", - "operationId" : "schedule/get-filtered-date", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-action-filtering1878129123" - }, - "examples" : { - "schedule/get-filtered-date" : { - "value" : "{\n \"event_id\" : \"123e4567-e89b-12d3-a456-426614174000\",\n \"names\" : [ \"memberName1\", \"memberName2\" ]\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-event_id-735087003" - }, - "examples" : { - "schedule/get-filtered-date" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"멤버 필터링 날짜 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"name\" : \"memberName1\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"2024.12.01\"\n } ]\n }, {\n \"name\" : \"memberName2\",\n \"schedules\" : [ {\n \"times\" : [ \"11:00\", \"12:00\" ],\n \"time_point\" : \"2024.12.02\"\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/date/{event_id}" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "이벤트에 대한 모든 날짜 스케줄을 조회한다.", - "description" : "이벤트에 대한 모든 날짜 스케줄을 조회한다.", - "operationId" : "schedule/get-all-date-schedules", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-event_id-735087003" - }, - "examples" : { - "schedule/get-all-date-schedules" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"전체 날짜 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"name\" : \"Test Member\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"2024-12-01\"\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/day/action-filtering" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "멤버 필터링 요일 스케줄을 조회한다.", - "description" : "멤버 필터링 요일 스케줄을 조회한다.", - "operationId" : "schedule/get-filtered-day-schedules", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day-action-filtering2085910463" - }, - "examples" : { - "schedule/get-filtered-day-schedules" : { - "value" : "{\n \"event_id\" : \"2b37fee0-be3b-43c6-9de0-8b54f3c6842e\",\n \"names\" : [ \"Test Member\" ]\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day-event_id361082201" - }, - "examples" : { - "schedule/get-filtered-day-schedules" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"멤버 필터링 요일 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"name\" : \"Test Member\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"월\"\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/day/{event_id}" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "이벤트에 대한 모든 요일 스케줄을 조회한다.", - "description" : "이벤트에 대한 모든 요일 스케줄을 조회한다.", - "operationId" : "schedule/get-all-day-schedules", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day-event_id361082201" - }, - "examples" : { - "schedule/get-all-day-schedules" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"전체 요일 스케줄 조회에 성공했습니다.\",\n \"payload\" : [ {\n \"name\" : \"Test Member\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"월\"\n } ]\n } ],\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/users/profile/action-update" : { - "patch" : { - "tags" : [ "User API" ], - "summary" : "유저 정보를 수정한다.", - "description" : "유저 정보를 수정한다.", - "operationId" : "user/update-profile", - "requestBody" : { - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-profile-action-update-1330045987" - }, - "examples" : { - "user/update-profile" : { - "value" : "{\n \"nickname\" : \"NewNickname\"\n}" - } - } - } - } - }, - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-users-action-withdraw710843682" - }, - "examples" : { - "user/update-profile" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"유저 정보 수정에 성공했습니다.\",\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/date/{event_id}/user" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "개인 날짜 스케줄을 조회한다. (로그인 유저)", - "description" : "개인 날짜 스케줄을 조회한다. (로그인 유저)", - "operationId" : "schedule/get-user-date", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-event_id-user-1766468563" - }, - "examples" : { - "schedule/get-user-date" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"개인(로그인) 날짜 스케줄 조회에 성공했습니다.\",\n \"payload\" : {\n \"name\" : \"userNickname\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"2024.12.01\"\n } ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/date/{event_id}/{member_id}" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "개인 날짜 스케줄을 조회한다. (비로그인 유저)", - "description" : "개인 날짜 스케줄을 조회한다. (비로그인 유저)", - "operationId" : "schedule/get-member-date", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "member_id", - "in" : "path", - "description" : "멤버 ID [예시 : 789e0123-e45b-67c8-d901-234567890abc]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-date-event_id-member_id-893097784" - }, - "examples" : { - "schedule/get-member-date" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"개인(비로그인) 날짜 스케줄 조회에 성공했습니다.\",\n \"payload\" : {\n \"name\" : \"memberName\",\n \"schedules\" : [ {\n \"times\" : [ \"09:00\", \"10:00\" ],\n \"time_point\" : \"2024.12.01\"\n } ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/day/{event_id}/user" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "개인 요일 스케줄을 조회한다. (로그인 유저)", - "description" : "개인 요일 스케줄을 조회한다. (로그인 유저)", - "operationId" : "schedule/get-user-day-schedules", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day-event_id-user1388746317" - }, - "examples" : { - "schedule/get-user-day-schedules" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"개인(로그인) 요일 스케줄 조회에 성공했습니다.\",\n \"payload\" : {\n \"name\" : \"Test User\",\n \"schedules\" : [ {\n \"times\" : [ \"13:00\", \"14:00\" ],\n \"time_point\" : \"수\"\n } ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - }, - "/api/v1/schedules/day/{event_id}/{member_id}" : { - "get" : { - "tags" : [ "Schedule API" ], - "summary" : "개인 요일 스케줄을 조회한다. (비로그인 유저)", - "description" : "개인 요일 스케줄을 조회한다. (비로그인 유저)", - "operationId" : "schedule/get-member-day-schedules", - "parameters" : [ { - "name" : "event_id", - "in" : "path", - "description" : "이벤트 ID [예시 : dd099816-2b09-4625-bf95-319672c25659]", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "name" : "member_id", - "in" : "path", - "description" : "멤버 ID [예시 : 789e0123-e45b-67c8-d901-234567890abc]", - "required" : true, - "schema" : { - "type" : "string" - } - } ], - "responses" : { - "200" : { - "description" : "200", - "content" : { - "application/json;charset=UTF-8" : { - "schema" : { - "$ref" : "#/components/schemas/api-v1-schedules-day-event_id-member_id-2071885996" - }, - "examples" : { - "schedule/get-member-day-schedules" : { - "value" : "{\n \"code\" : \"200\",\n \"message\" : \"개인(비로그인) 요일 스케줄 조회에 성공했습니다.\",\n \"payload\" : {\n \"name\" : \"Test Member\",\n \"schedules\" : [ {\n \"times\" : [ \"11:00\", \"12:00\" ],\n \"time_point\" : \"화\"\n } ]\n },\n \"is_success\" : true\n}" - } - } - } - } - } - } - } - } - }, - "components" : { - "schemas" : { - "api-v1-fixed-schedules-1734125469" : { - "required" : [ "schedules", "title" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "description" : "고정 스케줄 목록", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "title" : { - "type" : "string", - "description" : "스케줄 이름" - } - } - }, - "api-v1-members-action-register946525738" : { - "required" : [ "event_id", "name", "pin", "schedules" ], - "type" : "object", - "properties" : { - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "pin" : { - "type" : "string", - "description" : "멤버 PIN" - }, - "schedules" : { - "type" : "array", - "description" : "스케줄 목록", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "스케줄 날짜" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - }, - "api-v1-fixed-schedules-id507597251" : { - "required" : [ "schedules", "title" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "description" : "수정된 고정 스케줄 목록", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "title" : { - "type" : "string", - "description" : "수정된 스케줄 이름" - } - } - }, - "api-v1-fixed-schedules-by-day-day986652941" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "payload" : { - "type" : "array", - "items" : { - "required" : [ "end_time", "id", "start_time", "title" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "시작 시간" - }, - "end_time" : { - "type" : "string", - "description" : "종료 시간" - }, - "id" : { - "type" : "number", - "description" : "고정 스케줄 ID" - }, - "title" : { - "type" : "string", - "description" : "고정 스케줄 이름" - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "RemoveUserCreatedEventResponseSchema" : { - "title" : "RemoveUserCreatedEventResponseSchema", - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-date-1423118481" : { - "required" : [ "event_id" ], - "type" : "object", - "properties" : { - "member_id" : { - "type" : "string", - "description" : "멤버 ID (로그인 유저는 필요 없음)", - "nullable" : true - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "날짜" - } - } - } - } - } - }, - "api-v1-members-name-action-check-143782741" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "payload" : { - "required" : [ "is_possible" ], - "type" : "object", - "properties" : { - "is_possible" : { - "type" : "boolean", - "description" : "이름 사용 가능 여부" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-urls-action-original-1188642833" : { - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "original_url" ], - "type" : "object", - "properties" : { - "original_url" : { - "type" : "string", - "description" : "복원된 원본 URL" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-date-event_id-735087003" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "type" : "array", - "items" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "날짜" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "ModifyUserCreatedEventTitleResponseSchema" : { - "title" : "ModifyUserCreatedEventTitleResponseSchema", - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-day-event_id-user1388746317" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "사용자 이름" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "GetMostPossibleTimeResponseSchema" : { - "title" : "GetMostPossibleTimeResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "type" : "array", - "description" : "가장 많이 되는 시간 목록", - "items" : { - "required" : [ "end_time", "impossible_names", "possible_count", "possible_names", "start_time", "time_point" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "시작 시간" - }, - "time_point" : { - "type" : "string", - "description" : "날짜 또는 요일" - }, - "end_time" : { - "type" : "string", - "description" : "종료 시간" - }, - "possible_names" : { - "type" : "array", - "description" : "가능한 참여자 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "possible_count" : { - "type" : "number", - "description" : "가능한 참여자 수" - }, - "impossible_names" : { - "type" : "array", - "description" : "참여 불가능한 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-date-event_id-member_id-893097784" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "날짜" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-events-event_id1666935626" : { - "required" : [ "end_time", "ranges", "start_time", "title" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "시작 시간 (HH:mm)" - }, - "ranges" : { - "type" : "array", - "description" : "수정할 설문 범위 [예: 날짜 리스트]", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "end_time" : { - "type" : "string", - "description" : "종료 시간 (HH:mm)" - }, - "title" : { - "type" : "string", - "description" : "새로운 이벤트 제목" - } - } - }, - "api-v1-members-action-register1632683266" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "payload" : { - "required" : [ "category", "member_id" ], - "type" : "object", - "properties" : { - "member_id" : { - "type" : "string", - "description" : "멤버 ID" - }, - "category" : { - "type" : "string", - "description" : "이벤트 카테고리" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-date-action-filtering1878129123" : { - "required" : [ "event_id", "names" ], - "type" : "object", - "properties" : { - "names" : { - "type" : "array", - "description" : "이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - } - } - }, - "CreateEventRequestSchema" : { - "title" : "CreateEventRequestSchema", - "required" : [ "category", "end_time", "ranges", "start_time", "title" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "이벤트 시작 시간" - }, - "ranges" : { - "type" : "array", - "description" : "이벤트 날짜 또는 요일 범위", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "end_time" : { - "type" : "string", - "description" : "이벤트 종료 시간" - }, - "category" : { - "type" : "string", - "description" : "이벤트 카테고리" - }, - "title" : { - "type" : "string", - "description" : "이벤트 제목" - } - } - }, - "api-v1-schedules-day-event_id-member_id-2071885996" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "GetEventResponseSchema" : { - "title" : "GetEventResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "category", "end_time", "event_id", "event_status", "ranges", "start_time", "title" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "이벤트 시작 시간" - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "ranges" : { - "type" : "array", - "description" : "이벤트 날짜 또는 요일 범위", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "end_time" : { - "type" : "string", - "description" : "이벤트 종료 시간" - }, - "category" : { - "type" : "string", - "description" : "이벤트 카테고리" - }, - "title" : { - "type" : "string", - "description" : "이벤트 제목" - }, - "event_status" : { - "type" : "string", - "description" : "이벤트 상태 (로그인 유저만 반환)" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "GetEventQrCodeResponseSchema" : { - "title" : "GetEventQrCodeResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "qr_code_img_url" ], - "type" : "object", - "properties" : { - "qr_code_img_url" : { - "type" : "string", - "description" : "QR 코드 이미지 URL" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-tokens-action-reissue-869168215" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "access_token", "refresh_token" ], - "type" : "object", - "properties" : { - "access_token" : { - "type" : "string", - "description" : "새로운 액세스 토큰" - }, - "refresh_token" : { - "type" : "string", - "description" : "새로운 리프레쉬 토큰" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-date-event_id-user-1766468563" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "날짜" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "유저 닉네임" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-day-event_id361082201" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "type" : "array", - "items" : { - "required" : [ "name" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-fixed-schedules-1646561338" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "payload" : { - "type" : "array", - "items" : { - "required" : [ "id" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "id" : { - "type" : "number", - "description" : "고정 스케줄 ID" - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-tokens-action-reissue-1852733133" : { - "required" : [ "refresh_token" ], - "type" : "object", - "properties" : { - "refresh_token" : { - "type" : "string", - "description" : "기존 리프레쉬 토큰" - } - } - }, - "OnboardUserRequestSchema" : { - "title" : "OnboardUserRequestSchema", - "required" : [ "nickname", "register_token" ], - "type" : "object", - "properties" : { - "register_token" : { - "type" : "string", - "description" : "레지스터 토큰" - }, - "nickname" : { - "type" : "string", - "description" : "유저 닉네임" - } - } - }, - "api-v1-users-profile-action-update-1330045987" : { - "required" : [ "nickname" ], - "type" : "object", - "properties" : { - "nickname" : { - "type" : "string", - "description" : "수정할 닉네임" - } - } - }, - "GetParticipantsResponseSchema" : { - "title" : "GetParticipantsResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "names" ], - "type" : "object", - "properties" : { - "names" : { - "type" : "array", - "description" : "참여자 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "GetUserPolicyAgreementResponseSchema" : { - "title" : "GetUserPolicyAgreementResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "marketing_policy_agreement", "privacy_policy_agreement", "service_policy_agreement" ], - "type" : "object", - "properties" : { - "service_policy_agreement" : { - "type" : "boolean", - "description" : "서비스 이용약관 동의 여부" - }, - "marketing_policy_agreement" : { - "type" : "boolean", - "description" : "마케팅 정보 수신 동의 여부" - }, - "privacy_policy_agreement" : { - "type" : "boolean", - "description" : "개인정보 수집 및 이용 동의 여부" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-schedules-day485021249" : { - "required" : [ "event_id", "member_id" ], - "type" : "object", - "properties" : { - "member_id" : { - "type" : "string", - "description" : "멤버 ID" - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "스케줄 시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - } - } - }, - "api-v1-schedules-day-action-filtering2085910463" : { - "required" : [ "event_id", "names" ], - "type" : "object", - "properties" : { - "names" : { - "type" : "array", - "description" : "조회할 멤버 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - } - } - }, - "GetUserParticipatedEventsResponseSchema" : { - "title" : "GetUserParticipatedEventsResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "type" : "array", - "description" : "참여 이벤트 목록", - "items" : { - "required" : [ "category", "created_date", "event_id", "event_status", "most_possible_times", "participant_count", "title" ], - "type" : "object", - "properties" : { - "participant_count" : { - "type" : "number", - "description" : "참여자 수" - }, - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "created_date" : { - "type" : "string", - "description" : "이벤트 생성일" - }, - "most_possible_times" : { - "type" : "array", - "description" : "가장 많이 가능한 시간대", - "items" : { - "required" : [ "end_time", "impossible_names", "possible_count", "possible_names", "start_time", "time_point" ], - "type" : "object", - "properties" : { - "start_time" : { - "type" : "string", - "description" : "시작 시간" - }, - "time_point" : { - "type" : "string", - "description" : "날짜 또는 요일" - }, - "end_time" : { - "type" : "string", - "description" : "종료 시간" - }, - "possible_names" : { - "type" : "array", - "description" : "참여 가능한 유저 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "possible_count" : { - "type" : "number", - "description" : "가능한 참여자 수" - }, - "impossible_names" : { - "type" : "array", - "description" : "참여 불가능한 유저 이름 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - } - } - } - }, - "title" : { - "type" : "string", - "description" : "이벤트 제목" - }, - "category" : { - "type" : "string", - "description" : "이벤트 카테고리" - }, - "event_status" : { - "type" : "string", - "description" : "이벤트 참여 상태" - } - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "UpdateUserPolicyAgreementRequestSchema" : { - "title" : "UpdateUserPolicyAgreementRequestSchema", - "required" : [ "marketing_policy_agreement", "privacy_policy_agreement", "service_policy_agreement" ], - "type" : "object", - "properties" : { - "service_policy_agreement" : { - "type" : "boolean", - "description" : "서비스 이용약관 동의 여부" - }, - "marketing_policy_agreement" : { - "type" : "boolean", - "description" : "마케팅 정보 수신 동의 여부" - }, - "privacy_policy_agreement" : { - "type" : "boolean", - "description" : "개인정보 수집 및 이용 동의 여부" - } - } - }, - "OnboardUserResponseSchema" : { - "title" : "OnboardUserResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "access_token", "refresh_token" ], - "type" : "object", - "properties" : { - "access_token" : { - "type" : "string", - "description" : "액세스 토큰" - }, - "refresh_token" : { - "type" : "string", - "description" : "리프레쉬 토큰" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-urls-action-original-209534800" : { - "required" : [ "shorten_url" ], - "type" : "object", - "properties" : { - "shorten_url" : { - "type" : "string", - "description" : "복원할 단축 URL" - } - } - }, - "CreateEventResponseSchema" : { - "title" : "CreateEventResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "event_id" ], - "type" : "object", - "properties" : { - "event_id" : { - "type" : "string", - "description" : "생성된 이벤트의 UUID (형식: UUID)" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-urls-action-shorten1006350093" : { - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "shorten_url" ], - "type" : "object", - "properties" : { - "shorten_url" : { - "type" : "string", - "description" : "생성된 단축 URL" - } - }, - "description" : "응답 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-members-name-action-check-1509950010" : { - "required" : [ "event_id", "name" ], - "type" : "object", - "properties" : { - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "name" : { - "type" : "string", - "description" : "확인할 멤버 이름" - } - } - }, - "api-v1-fixed-schedules-id-1244436439" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "payload" : { - "required" : [ "title" ], - "type" : "object", - "properties" : { - "schedules" : { - "type" : "array", - "items" : { - "required" : [ "time_point", "times" ], - "type" : "object", - "properties" : { - "times" : { - "type" : "array", - "description" : "시간 목록", - "items" : { - "oneOf" : [ { - "type" : "object" - }, { - "type" : "boolean" - }, { - "type" : "string" - }, { - "type" : "number" - } ] - } - }, - "time_point" : { - "type" : "string", - "description" : "요일" - } - } - } - }, - "title" : { - "type" : "string", - "description" : "고정 스케줄 제목" - } - } - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "GetUserProfileResponseSchema" : { - "title" : "GetUserProfileResponseSchema", - "required" : [ "code", "is_success", "message", "payload" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "payload" : { - "required" : [ "email", "nickname" ], - "type" : "object", - "properties" : { - "nickname" : { - "type" : "string", - "description" : "유저 닉네임" - }, - "email" : { - "type" : "string", - "description" : "유저 이메일" - } - }, - "description" : "유저 정보 데이터" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-urls-action-shorten1745576439" : { - "required" : [ "original_url" ], - "type" : "object", - "properties" : { - "original_url" : { - "type" : "string", - "description" : "단축할 원본 URL" - } - } - }, - "api-v1-users-action-withdraw710843682" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "응답 코드" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - }, - "api-v1-members-action-login-1923712789" : { - "required" : [ "event_id", "name", "pin" ], - "type" : "object", - "properties" : { - "event_id" : { - "type" : "string", - "description" : "이벤트 ID" - }, - "pin" : { - "type" : "string", - "description" : "멤버 PIN" - }, - "name" : { - "type" : "string", - "description" : "멤버 이름" - } - } - }, - "api-v1-fixed-schedules-id-1529947151" : { - "required" : [ "code", "is_success", "message" ], - "type" : "object", - "properties" : { - "code" : { - "type" : "string", - "description" : "HTTP 상태 코드" - }, - "message" : { - "type" : "string", - "description" : "응답 메시지" - }, - "is_success" : { - "type" : "boolean", - "description" : "성공 여부" - } - } - } - } - } -} \ No newline at end of file From 93a1839afcb443e0370b5aba866e9bbc30d2ea3e Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:37:31 +0900 Subject: [PATCH 02/30] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=EC=97=90=20=EC=9B=90=EC=9E=90=EC=A0=81?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Race condition 방지를 위한 원자적 토큰 상태 업데이트 - 토큰 값 검증 로직 추가 (DB 토큰과 요청 토큰 비교) - Grace Period 내 중복 요청 처리 - 토큰 재사용 공격 탐지 시 family 전체 revoke Co-Authored-By: Claude Opus 4.5 --- .../onetime/controller/TokenController.java | 12 +- .../side/onetime/service/TokenService.java | 122 ++++++++++++++---- 2 files changed, 103 insertions(+), 31 deletions(-) diff --git a/src/main/java/side/onetime/controller/TokenController.java b/src/main/java/side/onetime/controller/TokenController.java index 0ac7b2e2..175b585d 100644 --- a/src/main/java/side/onetime/controller/TokenController.java +++ b/src/main/java/side/onetime/controller/TokenController.java @@ -1,12 +1,14 @@ package side.onetime.controller; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; import side.onetime.dto.token.request.ReissueTokenRequest; import side.onetime.dto.token.response.ReissueTokenResponse; import side.onetime.global.common.ApiResponse; @@ -24,13 +26,15 @@ public class TokenController { * 액세스 토큰 재발행 API. * * @param reissueAccessTokenRequest 리프레쉬 토큰을 포함한 요청 객체 + * @param httpRequest HttpServletRequest (IP, User-Agent 추출용) * @return 재발행된 액세스 토큰과 리프레쉬 토큰을 포함하는 응답 객체 */ @PostMapping("/action-reissue") public ResponseEntity> reissueToken( - @Valid @RequestBody ReissueTokenRequest reissueAccessTokenRequest) { + @Valid @RequestBody ReissueTokenRequest reissueAccessTokenRequest, + HttpServletRequest httpRequest) { - ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest); + ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest, httpRequest); return ApiResponse.onSuccess(SuccessStatus._REISSUE_TOKENS, reissueTokenResponse); } } diff --git a/src/main/java/side/onetime/service/TokenService.java b/src/main/java/side/onetime/service/TokenService.java index cd5ae55d..92cb7184 100644 --- a/src/main/java/side/onetime/service/TokenService.java +++ b/src/main/java/side/onetime/service/TokenService.java @@ -1,68 +1,136 @@ package side.onetime.service; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; import side.onetime.domain.RefreshToken; +import side.onetime.domain.enums.TokenStatus; import side.onetime.dto.token.request.ReissueTokenRequest; import side.onetime.dto.token.response.ReissueTokenResponse; import side.onetime.exception.CustomException; import side.onetime.exception.status.TokenErrorStatus; -import side.onetime.global.lock.annotation.DistributedLock; import side.onetime.repository.RefreshTokenRepository; +import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; +/** + * 토큰 서비스 + * + * Token Rotation + Grace Period를 적용한 Refresh Token 재발급 처리 + */ @Slf4j @Service @RequiredArgsConstructor public class TokenService { + private static final int GRACE_PERIOD_SECONDS = 3; + private final RefreshTokenRepository refreshTokenRepository; private final JwtUtil jwtUtil; + private final ClientInfoExtractor clientInfoExtractor; /** * 리프레시 토큰으로 액세스/리프레시 토큰을 재발행 하는 메서드. * - * - 리프레시 토큰에서 userId, browserId 추출 - * - 동일 browserId에 대해 최근 요청 이력이 존재하면 쿨다운 예외 발생 (0.5초 제한) - * - Redis에서 저장된 리프레시 토큰과 비교하여 유효성 검증 - * - 새로운 액세스/리프레시 토큰 발급 및 Redis에 저장 - * - 중복 재발행을 방지하기 위해 refreshToken 단위로 분산 락(@DistributedLock) 적용 - * - * [예외 처리] - * - 저장된 토큰이 없거나 일치하지 않으면 400 에러 반환 - * - 너무 자주 요청 시 429 에러 반환 + * Token Rotation 전략 적용: + * - ACTIVE 토큰 → 정상 재발급, 기존 토큰은 ROTATED 처리 (원자적 업데이트) + * - ROTATED 토큰 (Grace Period 내) → 중복 요청으로 간주, 429 에러 + * - ROTATED 토큰 (Grace Period 초과) → 공격 탐지, family 전체 revoke + * - REVOKED/EXPIRED 토큰 → 재로그인 필요 * * @param reissueTokenRequest 요청 객체 (리프레시 토큰 포함) + * @param httpRequest HttpServletRequest (IP, User-Agent 추출용) * @return 새 액세스/리프레시 토큰 * @throws CustomException 유효하지 않은 토큰이거나 요청이 너무 잦을 경우 */ - @DistributedLock(prefix = "lock:reissue", key = "#reissueTokenRequest.refreshToken", waitTime = 0) - public ReissueTokenResponse reissueToken(ReissueTokenRequest reissueTokenRequest) { + @Transactional + public ReissueTokenResponse reissueToken(ReissueTokenRequest reissueTokenRequest, HttpServletRequest httpRequest) { String refreshToken = reissueTokenRequest.refreshToken(); - Long userId = jwtUtil.getClaimFromToken(refreshToken, "userId", Long.class); - String browserId = jwtUtil.getClaimFromToken(refreshToken, "browserId", String.class); + String jti = jwtUtil.getClaimFromToken(refreshToken, "jti", String.class); + String userIp = clientInfoExtractor.extractClientIp(httpRequest); + String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); - // 쿨다운 체크 - if (refreshTokenRepository.isInCooldown(userId, browserId)) { - throw new CustomException(TokenErrorStatus._TOO_MANY_REQUESTS); + RefreshToken token = refreshTokenRepository.findByJti(jti) + .orElseThrow(() -> new CustomException(TokenErrorStatus._NOT_FOUND_REFRESH_TOKEN)); + + // 토큰 값 검증: DB에 저장된 토큰과 요청 토큰 비교 + if (!token.getTokenValue().equals(refreshToken)) { + throw new CustomException(TokenErrorStatus._INVALID_REFRESH_TOKEN); } - String existRefreshToken = refreshTokenRepository.findByUserIdAndBrowserId(userId, browserId) - .orElseThrow(() -> new CustomException(TokenErrorStatus._NOT_FOUND_REFRESH_TOKEN)); + // 1. ACTIVE 토큰 → 정상 재발급 + if (token.isActive()) { + return rotateToken(token, userIp, userAgent); + } - if (!existRefreshToken.equals(refreshToken)) { - throw new CustomException(TokenErrorStatus._NOT_FOUND_REFRESH_TOKEN); + // 2. ROTATED 토큰 → Grace Period 체크 + if (token.getStatus() == TokenStatus.ROTATED) { + if (isWithinGracePeriod(token)) { + // 중복 요청 → 무시 + throw new CustomException(TokenErrorStatus._DUPLICATED_REQUEST); + } else { + // 공격 탐지 → family 전체 revoke + log.warn("[Token Reuse Detected] familyId={}, jti={}, ip={}", + token.getFamilyId(), jti, userIp); + refreshTokenRepository.revokeAllByFamilyId(token.getFamilyId()); + throw new CustomException(TokenErrorStatus._TOKEN_REUSE_DETECTED); + } } - String newAccessToken = jwtUtil.generateAccessToken(userId, "USER"); - String newRefreshToken = jwtUtil.generateRefreshToken(userId, browserId); - refreshTokenRepository.save(new RefreshToken(userId, browserId, newRefreshToken)); + // 3. REVOKED, EXPIRED → 재로그인 필요 + throw new CustomException(TokenErrorStatus._INVALID_REFRESH_TOKEN); + } - // 쿨다운 설정 (0.5초) - refreshTokenRepository.setCooldown(userId, browserId, 500); + /** + * Token Rotation 수행 (원자적 업데이트) + * + * 기존 토큰을 ROTATED 상태로 변경하고 새 토큰을 생성합니다. + * 원자적 업데이트를 사용하여 동시 요청 시 race condition을 방지합니다. + * + * @param oldToken 기존 토큰 + * @param userIp 요청 IP + * @param userAgent 요청 User-Agent + * @return 새 토큰 응답 + * @throws CustomException 토큰이 이미 사용된 경우 (동시 요청으로 인한 race condition) + */ + private ReissueTokenResponse rotateToken(RefreshToken oldToken, String userIp, String userAgent) { + LocalDateTime now = LocalDateTime.now(); + + // 원자적 업데이트: ACTIVE 상태인 경우에만 ROTATED로 변경 + int updated = refreshTokenRepository.markAsRotatedIfActive(oldToken.getId(), now, userIp); + if (updated == 0) { + // 이미 다른 요청에서 토큰을 rotate 했음 (race condition) + throw new CustomException(TokenErrorStatus._ALREADY_USED_REFRESH_TOKEN); + } + + // 새 토큰 생성 + String newJti = UUID.randomUUID().toString(); + String newAccessToken = jwtUtil.generateAccessToken(oldToken.getUserId(), "USER"); + String newRefreshToken = jwtUtil.generateRefreshToken(oldToken.getUserId(), oldToken.getBrowserId(), newJti); + + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); + + RefreshToken newToken = oldToken.rotate(newJti, newRefreshToken, now, expiryAt, userIp, userAgent); + refreshTokenRepository.save(newToken); return ReissueTokenResponse.of(newAccessToken, newRefreshToken); } + + /** + * Grace Period (3초) 내인지 확인 + * + * @param token 확인할 토큰 + * @return Grace Period 내 여부 + */ + private boolean isWithinGracePeriod(RefreshToken token) { + return token.getLastUsedAt() != null && + token.getLastUsedAt().plusSeconds(GRACE_PERIOD_SECONDS).isAfter(LocalDateTime.now()); + } } From 800f5afb7095b4ed3d1fcc31d628a407038627f3 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:37:45 +0900 Subject: [PATCH 03/30] =?UTF-8?q?feat:=20ClientInfoExtractor=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=97=90=20IP/UserAgent=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientInfoExtractor 유틸 클래스 추가 (IP, UserAgent 추출) - OAuth 로그인 및 온보딩 시 클라이언트 정보 저장 - 테스트 로그인에도 클라이언트 정보 저장 적용 Co-Authored-By: Claude Opus 4.5 --- .../handler/OAuthLoginSuccessHandler.java | 46 ++++++++++----- .../onetime/controller/UserController.java | 32 +++++++++-- .../side/onetime/service/TestAuthService.java | 27 +++++++-- .../side/onetime/service/UserService.java | 56 ++++++++++++++----- .../onetime/util/ClientInfoExtractor.java | 54 ++++++++++++++++++ 5 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 src/main/java/side/onetime/util/ClientInfoExtractor.java diff --git a/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java b/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java index d1c384dd..9c9b4ceb 100644 --- a/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java +++ b/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java @@ -1,14 +1,22 @@ package side.onetime.auth.handler; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import side.onetime.auth.dto.GoogleUserInfo; import side.onetime.auth.dto.KakaoUserInfo; import side.onetime.auth.dto.NaverUserInfo; @@ -17,13 +25,9 @@ import side.onetime.domain.User; import side.onetime.repository.RefreshTokenRepository; import side.onetime.repository.UserRepository; +import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Map; - @Slf4j @Component @RequiredArgsConstructor @@ -38,6 +42,7 @@ public class OAuthLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHand private final JwtUtil jwtUtil; private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; + private final ClientInfoExtractor clientInfoExtractor; /** * OAuth2 인증 성공 처리 메서드. @@ -141,12 +146,27 @@ private void handleNewUser(HttpServletRequest request, HttpServletResponse respo private void handleExistingUser(HttpServletRequest request, HttpServletResponse response, User user) throws IOException { Long userId = user.getId(); String browserId = jwtUtil.hashUserAgent(request.getHeader("User-Agent")); + String userIp = clientInfoExtractor.extractClientIp(request); + String userAgent = clientInfoExtractor.extractUserAgent(request); + + // 기존 브라우저의 ACTIVE 토큰 revoke + refreshTokenRepository.revokeByUserIdAndBrowserId(userId, browserId); + + // 새 토큰 생성 + String jti = UUID.randomUUID().toString(); + String accessToken = jwtUtil.generateAccessToken(userId, "USER"); + String refreshTokenValue = jwtUtil.generateRefreshToken(userId, browserId, jti); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); - String accessToken = jwtUtil.generateAccessToken(userId,"USER"); - String refreshToken = jwtUtil.generateRefreshToken(userId, browserId); - refreshTokenRepository.save(new RefreshToken(userId, browserId, refreshToken)); + RefreshToken refreshToken = RefreshToken.create( + userId, jti, browserId, refreshTokenValue, + now, expiryAt, userIp, userAgent + ); + refreshTokenRepository.save(refreshToken); - String redirectUri = String.format(ACCESS_TOKEN_REDIRECT_URI, "true", accessToken, refreshToken); + String redirectUri = String.format(ACCESS_TOKEN_REDIRECT_URI, "true", accessToken, refreshTokenValue); getRedirectStrategy().sendRedirect(request, response, redirectUri); } } diff --git a/src/main/java/side/onetime/controller/UserController.java b/src/main/java/side/onetime/controller/UserController.java index b4bbbd50..e0237d93 100644 --- a/src/main/java/side/onetime/controller/UserController.java +++ b/src/main/java/side/onetime/controller/UserController.java @@ -1,12 +1,31 @@ package side.onetime.controller; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; import side.onetime.domain.enums.GuideType; -import side.onetime.dto.user.request.*; -import side.onetime.dto.user.response.*; +import side.onetime.dto.user.request.CreateGuideViewLogRequest; +import side.onetime.dto.user.request.LogoutUserRequest; +import side.onetime.dto.user.request.OnboardUserRequest; +import side.onetime.dto.user.request.UpdateUserPolicyAgreementRequest; +import side.onetime.dto.user.request.UpdateUserProfileRequest; +import side.onetime.dto.user.request.UpdateUserSleepTimeRequest; +import side.onetime.dto.user.response.GetGuideViewLogResponse; +import side.onetime.dto.user.response.GetUserPolicyAgreementResponse; +import side.onetime.dto.user.response.GetUserProfileResponse; +import side.onetime.dto.user.response.GetUserSleepTimeResponse; +import side.onetime.dto.user.response.OnboardUserResponse; import side.onetime.global.common.ApiResponse; import side.onetime.global.common.status.SuccessStatus; import side.onetime.service.UserService; @@ -28,9 +47,10 @@ public class UserController { */ @PostMapping("/onboarding") public ResponseEntity> onboardUser( - @Valid @RequestBody OnboardUserRequest onboardUserRequest) { + @Valid @RequestBody OnboardUserRequest onboardUserRequest, + HttpServletRequest httpRequest) { - OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest); + OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest, httpRequest); return ApiResponse.onSuccess(SuccessStatus._ONBOARD_USER, onboardUserResponse); } diff --git a/src/main/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index 146c575c..693e22c8 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -1,5 +1,8 @@ package side.onetime.service; +import java.time.LocalDateTime; +import java.util.UUID; + import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @@ -42,14 +45,26 @@ public OnboardUserResponse login(TestLoginRequest request) { } // 2. 고정된 테스트 유저 ID로 토큰 생성 - String accessToken = jwtUtil.generateAccessToken(testUserId, "USER"); String browserId = jwtUtil.hashUserAgent("E2E-Test-Agent"); - String refreshToken = jwtUtil.generateRefreshToken(testUserId, browserId); - // 3. Refresh Token Redis 저장 - RefreshToken token = new RefreshToken(testUserId, browserId, refreshToken); - refreshTokenRepository.save(token); + // 기존 브라우저의 ACTIVE 토큰 revoke + refreshTokenRepository.revokeByUserIdAndBrowserId(testUserId, browserId); + + // 새 토큰 생성 + String jti = UUID.randomUUID().toString(); + String accessToken = jwtUtil.generateAccessToken(testUserId, "USER"); + String refreshTokenValue = jwtUtil.generateRefreshToken(testUserId, browserId, jti); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(LocalDateTime.now()); + + // 3. Refresh Token MySQL 저장 + RefreshToken refreshToken = RefreshToken.create( + testUserId, jti, browserId, refreshTokenValue, + now, expiryAt, "127.0.0.1", "E2E-Test-Agent" + ); + refreshTokenRepository.save(refreshToken); - return OnboardUserResponse.of(accessToken, refreshToken); + return OnboardUserResponse.of(accessToken, refreshTokenValue); } } diff --git a/src/main/java/side/onetime/service/UserService.java b/src/main/java/side/onetime/service/UserService.java index cc82acee..8773ec21 100644 --- a/src/main/java/side/onetime/service/UserService.java +++ b/src/main/java/side/onetime/service/UserService.java @@ -1,24 +1,38 @@ package side.onetime.service; -import lombok.RequiredArgsConstructor; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; import side.onetime.domain.GuideViewLog; import side.onetime.domain.RefreshToken; import side.onetime.domain.User; import side.onetime.domain.enums.GuideType; -import side.onetime.dto.user.request.*; -import side.onetime.dto.user.response.*; +import side.onetime.dto.user.request.CreateGuideViewLogRequest; +import side.onetime.dto.user.request.LogoutUserRequest; +import side.onetime.dto.user.request.OnboardUserRequest; +import side.onetime.dto.user.request.UpdateUserPolicyAgreementRequest; +import side.onetime.dto.user.request.UpdateUserProfileRequest; +import side.onetime.dto.user.request.UpdateUserSleepTimeRequest; +import side.onetime.dto.user.response.GetGuideViewLogResponse; +import side.onetime.dto.user.response.GetUserPolicyAgreementResponse; +import side.onetime.dto.user.response.GetUserProfileResponse; +import side.onetime.dto.user.response.GetUserSleepTimeResponse; +import side.onetime.dto.user.response.OnboardUserResponse; import side.onetime.exception.CustomException; import side.onetime.exception.status.UserErrorStatus; import side.onetime.repository.GuideViewLogRepository; import side.onetime.repository.RefreshTokenRepository; import side.onetime.repository.UserRepository; +import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; import side.onetime.util.UserAuthorizationUtil; -import java.util.Optional; - @Service @RequiredArgsConstructor public class UserService { @@ -27,18 +41,20 @@ public class UserService { private final UserRepository userRepository; private final JwtUtil jwtUtil; private final GuideViewLogRepository guideViewLogRepository; + private final ClientInfoExtractor clientInfoExtractor; /** * 유저 온보딩 처리 메서드. * * 회원가입 이후 필수 정보를 설정하고 유저를 저장한 뒤, 액세스 토큰과 리프레쉬 토큰을 발급합니다. - * 리프레쉬 토큰은 브라우저 식별자(browserId)와 함께 Redis에 저장됩니다. + * 리프레쉬 토큰은 브라우저 식별자(browserId)와 함께 MySQL에 저장됩니다. * * @param request 유저의 레지스터 토큰, 닉네임, 약관 동의, 수면 시간 등 온보딩 정보가 포함된 요청 객체 + * @param httpRequest 클라이언트 정보 추출을 위한 HttpServletRequest * @return 발급된 액세스 토큰과 리프레쉬 토큰을 포함한 응답 객체 */ @Transactional - public OnboardUserResponse onboardUser(OnboardUserRequest request) { + public OnboardUserResponse onboardUser(OnboardUserRequest request, HttpServletRequest httpRequest) { String registerToken = request.registerToken(); jwtUtil.validateToken(registerToken); @@ -52,11 +68,24 @@ public OnboardUserResponse onboardUser(OnboardUserRequest request) { Long userId = newUser.getId(); String browserId = jwtUtil.getClaimFromToken(registerToken, "browserId", String.class); + String userIp = clientInfoExtractor.extractClientIp(httpRequest); + String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); + + // 새 토큰 생성 + String jti = UUID.randomUUID().toString(); String accessToken = jwtUtil.generateAccessToken(userId, "USER"); - String refreshToken = jwtUtil.generateRefreshToken(userId, browserId); - refreshTokenRepository.save(new RefreshToken(userId, browserId, refreshToken)); + String refreshTokenValue = jwtUtil.generateRefreshToken(userId, browserId, jti); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); - return OnboardUserResponse.of(accessToken, refreshToken); + RefreshToken refreshToken = RefreshToken.create( + userId, jti, browserId, refreshTokenValue, + now, expiryAt, userIp, userAgent + ); + refreshTokenRepository.save(refreshToken); + + return OnboardUserResponse.of(accessToken, refreshTokenValue); } /** @@ -120,14 +149,13 @@ public void updateUserProfile(UpdateUserProfileRequest updateUserProfileRequest) * 유저 서비스 탈퇴 메서드. * * 인증된 유저의 계정을 삭제합니다. - * + * (RefreshToken revoke는 userRepository.withdraw() 내부에서 처리) */ @Transactional public void withdrawUser() { User user = userRepository.findById(UserAuthorizationUtil.getLoginUserId()) .orElseThrow(() -> new CustomException(UserErrorStatus._NOT_FOUND_USER)); userRepository.withdraw(user); - refreshTokenRepository.deleteAllByUserId(user.getId()); } /** @@ -196,7 +224,7 @@ public void updateUserSleepTime(UpdateUserSleepTimeRequest request) { /** * 유저 로그아웃 메서드. * - * 로그아웃 시, 리프레쉬 토큰을 제거합니다. + * 로그아웃 시, 해당 브라우저의 리프레쉬 토큰을 REVOKED 상태로 변경합니다. * * @param request 리프레쉬 토큰 요청 데이터 */ @@ -206,7 +234,7 @@ public void logoutUser(LogoutUserRequest request) { jwtUtil.validateToken(refreshToken); Long userId = jwtUtil.getClaimFromToken(refreshToken, "userId", Long.class); String browserId = jwtUtil.getClaimFromToken(refreshToken, "browserId", String.class); - refreshTokenRepository.deleteRefreshToken(userId, browserId); + refreshTokenRepository.revokeByUserIdAndBrowserId(userId, browserId); } /** diff --git a/src/main/java/side/onetime/util/ClientInfoExtractor.java b/src/main/java/side/onetime/util/ClientInfoExtractor.java new file mode 100644 index 00000000..21d184a7 --- /dev/null +++ b/src/main/java/side/onetime/util/ClientInfoExtractor.java @@ -0,0 +1,54 @@ +package side.onetime.util; + +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 클라이언트 정보 추출 유틸리티 + * + * HttpServletRequest에서 IP 주소, User-Agent 등의 클라이언트 정보를 추출 + */ +@Component +public class ClientInfoExtractor { + + private static final String[] IP_HEADERS = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_X_FORWARDED_FOR" + }; + + /** + * 클라이언트 IP 주소 추출 + * + * 프록시/로드밸런서 환경을 고려하여 X-Forwarded-For 등의 헤더 확인 후 + * 없으면 remoteAddr 반환 + * + * @param request HttpServletRequest + * @return 클라이언트 IP 주소 + */ + public String extractClientIp(HttpServletRequest request) { + for (String header : IP_HEADERS) { + String ip = request.getHeader(header); + if (ip != null && !ip.isEmpty() && !"unknown".equalsIgnoreCase(ip)) { + return ip.split(",")[0].trim(); + } + } + return request.getRemoteAddr(); + } + + /** + * User-Agent 추출 + * + * @param request HttpServletRequest + * @return User-Agent 문자열 (최대 512자) + */ + public String extractUserAgent(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + if (userAgent == null) { + return null; + } + return userAgent.length() > 512 ? userAgent.substring(0, 512) : userAgent; + } +} From cab83dcb1a89a101cf96de40b52951ffb9c5a2db Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:37:58 +0900 Subject: [PATCH 04/30] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=8B=9C=20RefreshToken=20revoke=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원 탈퇴(withdraw) 시 해당 유저의 모든 ACTIVE 토큰을 REVOKED로 변경 Co-Authored-By: Claude Opus 4.5 --- .../repository/custom/UserRepositoryImpl.java | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java index 0ee8f48d..efd640c2 100644 --- a/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/UserRepositoryImpl.java @@ -1,33 +1,37 @@ package side.onetime.repository.custom; +import static side.onetime.domain.QEvent.*; +import static side.onetime.domain.QEventParticipation.*; +import static side.onetime.domain.QFixedSelection.*; +import static side.onetime.domain.QGuideViewLog.*; +import static side.onetime.domain.QMember.*; +import static side.onetime.domain.QRefreshToken.*; +import static side.onetime.domain.QSchedule.*; +import static side.onetime.domain.QSelection.*; +import static side.onetime.domain.QUser.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Pageable; + import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; import side.onetime.domain.User; import side.onetime.domain.enums.EventStatus; import side.onetime.domain.enums.Language; import side.onetime.domain.enums.Status; +import side.onetime.domain.enums.TokenStatus; import side.onetime.exception.CustomException; import side.onetime.exception.status.AdminErrorStatus; import side.onetime.util.NamingUtil; -import java.time.LocalDateTime; -import java.util.List; - -import static side.onetime.domain.QEvent.event; -import static side.onetime.domain.QEventParticipation.eventParticipation; -import static side.onetime.domain.QFixedSelection.fixedSelection; -import static side.onetime.domain.QGuideViewLog.guideViewLog; -import static side.onetime.domain.QMember.member; -import static side.onetime.domain.QSchedule.schedule; -import static side.onetime.domain.QSelection.selection; -import static side.onetime.domain.QUser.user; - @RequiredArgsConstructor public class UserRepositoryImpl implements UserRepositoryCustom { @@ -101,6 +105,13 @@ public void withdraw(User activeUser) { .where(guideViewLog.user.eq(activeUser)) .execute(); + // RefreshToken revoke 처리 + queryFactory.update(refreshToken) + .set(refreshToken.status, TokenStatus.REVOKED) + .where(refreshToken.userId.eq(activeUser.getId()) + .and(refreshToken.status.eq(TokenStatus.ACTIVE))) + .execute(); + queryFactory.update(user) .set(user.providerId, Expressions.nullExpression()) .set(user.status, Status.DELETED) From b6450c42b94f9e7ea0c26d4cf620cdf52e2026f0 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:38:11 +0900 Subject: [PATCH 05/30] =?UTF-8?q?refactor:=20JwtUtil=EC=97=90=20expiryAt?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - calculateRefreshTokenExpiryAt 메서드 추가 - 토큰 만료 시간 계산 로직 중복 제거 Co-Authored-By: Claude Opus 4.5 --- src/main/java/side/onetime/util/JwtUtil.java | 50 +++++++++++++++----- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/main/java/side/onetime/util/JwtUtil.java b/src/main/java/side/onetime/util/JwtUtil.java index a1be4d76..7f68cfed 100644 --- a/src/main/java/side/onetime/util/JwtUtil.java +++ b/src/main/java/side/onetime/util/JwtUtil.java @@ -1,26 +1,33 @@ package side.onetime.util; -import io.jsonwebtoken.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; import side.onetime.domain.User; import side.onetime.exception.CustomException; import side.onetime.exception.status.TokenErrorStatus; import side.onetime.exception.status.UserErrorStatus; import side.onetime.repository.UserRepository; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; -import java.util.Date; - @Slf4j @Component @RequiredArgsConstructor @@ -114,12 +121,14 @@ public String generateRegisterToken(String provider, String providerId, String n * * @param userId 유저 ID * @param browserId 브라우저 식별값 (User-Agent 기반 해시) + * @param jti JWT 고유 식별자 (Token Rotation 추적용) * @return 생성된 리프레시 토큰 */ - public String generateRefreshToken(Long userId, String browserId) { + public String generateRefreshToken(Long userId, String browserId, String jti) { return Jwts.builder() .claim("userId", userId) .claim("browserId", browserId) + .claim("jti", jti) .claim("type", "REFRESH_TOKEN") .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME)) @@ -127,6 +136,25 @@ public String generateRefreshToken(Long userId, String browserId) { .compact(); } + /** + * 리프레시 토큰 만료 시간 반환 (밀리초) + * + * @return 리프레시 토큰 만료 시간 (ms) + */ + public long getRefreshTokenExpirationTime() { + return REFRESH_TOKEN_EXPIRATION_TIME; + } + + /** + * 리프레시 토큰 만료 시각 계산 + * + * @param issuedAt 발급 시각 + * @return 만료 시각 (issuedAt + REFRESH_TOKEN_EXPIRATION_TIME) + */ + public LocalDateTime calculateRefreshTokenExpiryAt(LocalDateTime issuedAt) { + return issuedAt.plusSeconds(REFRESH_TOKEN_EXPIRATION_TIME / 1000); + } + /** * Authorization 헤더에서 Bearer 토큰 추출. * From 0baaa040b7084a51db70636684a144c1c512833b Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:39:44 +0900 Subject: [PATCH 06/30] =?UTF-8?q?[feat]=20:=20RefreshToken=20Cleanup=20Sch?= =?UTF-8?q?eduler=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만료된 토큰 상태 업데이트 (ACTIVE → EXPIRED) 스케줄러 추가 - 오래된 비활성 토큰(REVOKED/EXPIRED/ROTATED) hard delete 스케줄러 추가 - 스케줄러 cron 및 retention-days 설정을 YAML로 외부화 - 기본값: 매일 03:00 만료 처리, 03:30 hard delete, 30일 보관 Co-Authored-By: Claude Opus 4.5 --- .../service/RefreshTokenCleanupScheduler.java | 54 +++++++++++++++++++ src/main/resources/application.yaml | 6 +++ 2 files changed, 60 insertions(+) create mode 100644 src/main/java/side/onetime/service/RefreshTokenCleanupScheduler.java diff --git a/src/main/java/side/onetime/service/RefreshTokenCleanupScheduler.java b/src/main/java/side/onetime/service/RefreshTokenCleanupScheduler.java new file mode 100644 index 00000000..9121631b --- /dev/null +++ b/src/main/java/side/onetime/service/RefreshTokenCleanupScheduler.java @@ -0,0 +1,54 @@ +package side.onetime.service; + +import java.time.LocalDateTime; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import side.onetime.repository.RefreshTokenRepository; + +/** + * Refresh Token 정리 스케줄러 + * + * - 만료된 토큰 상태 업데이트 (ACTIVE → EXPIRED) + * - 오래된 비활성 토큰 hard delete (REVOKED/EXPIRED/ROTATED 물리적 삭제) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RefreshTokenCleanupScheduler { + + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${refresh-token.cleanup.retention-days:30}") + private int retentionDays; + + /** + * 만료된 토큰 상태 업데이트 + * + * ACTIVE 상태이면서 expiry_at이 지난 토큰을 EXPIRED로 변경 + */ + @Scheduled(cron = "${refresh-token.cleanup.update-expired-cron:0 0 3 * * *}") + @Transactional + public void updateExpiredTokens() { + int count = refreshTokenRepository.updateExpiredTokens(LocalDateTime.now()); + log.info("[RefreshToken Cleanup] 만료 토큰 상태 업데이트: {}건", count); + } + + /** + * 오래된 비활성 토큰 hard delete + * + * REVOKED, EXPIRED, ROTATED 상태이면서 retention-days 이상 지난 토큰을 물리적으로 삭제 + */ + @Scheduled(cron = "${refresh-token.cleanup.hard-delete-cron:0 30 3 * * *}") + @Transactional + public void hardDeleteOldInactiveTokens() { + LocalDateTime threshold = LocalDateTime.now().minusDays(retentionDays); + int count = refreshTokenRepository.hardDeleteOldInactiveTokens(threshold); + log.info("[RefreshToken Cleanup] 오래된 토큰 hard delete: {}건 (retention: {}일)", count, retentionDays); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cf94ddc4..81d58da0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -63,6 +63,12 @@ jwt: scheduling: cron: ${CRON} +refresh-token: + cleanup: + update-expired-cron: ${REFRESH_TOKEN_UPDATE_EXPIRED_CRON:0 0 3 * * *} + hard-delete-cron: ${REFRESH_TOKEN_HARD_DELETE_CRON:0 30 3 * * *} + retention-days: ${REFRESH_TOKEN_RETENTION_DAYS:30} + springdoc: swagger-ui: path: /swagger-ui.html From 1f0d42c18178e1dbfddc20b5811ca9e5ae1fc206 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:40:00 +0900 Subject: [PATCH 07/30] =?UTF-8?q?[test]=20:=20DB=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20Tes?= =?UTF-8?q?tcontainers=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Testcontainers MySQL 의존성 추가 (H2 제거) - Singleton Container Pattern으로 DatabaseTestConfig 구현 - 테스트 환경에서 실제 MySQL과 동일한 환경으로 테스트 가능 - @ServiceConnection과 @DynamicPropertySource로 DB 연결 자동화 Co-Authored-By: Claude Opus 4.5 --- build.gradle | 6 +++ .../configuration/DatabaseTestConfig.java | 44 +++++++++++++++++++ src/test/resources/application.yaml | 37 ++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/test/java/side/onetime/configuration/DatabaseTestConfig.java diff --git a/build.gradle b/build.gradle index 4f9d925a..cb7a19d2 100644 --- a/build.gradle +++ b/build.gradle @@ -94,6 +94,12 @@ dependencies { // Jsoup implementation 'org.jsoup:jsoup:1.21.2' + + // Testcontainers + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter:1.19.0' + testImplementation 'org.testcontainers:mysql:1.20.1' } // QueryDSL 디렉토리 diff --git a/src/test/java/side/onetime/configuration/DatabaseTestConfig.java b/src/test/java/side/onetime/configuration/DatabaseTestConfig.java new file mode 100644 index 00000000..6362db10 --- /dev/null +++ b/src/test/java/side/onetime/configuration/DatabaseTestConfig.java @@ -0,0 +1,44 @@ +package side.onetime.configuration; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.TestMethodOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; + +/** + * Testcontainers를 사용한 MySQL 테스트 설정 + * + * Singleton Container 패턴을 사용하여 테스트 실행 중 하나의 컨테이너만 사용합니다. + */ +@TestMethodOrder(MethodOrderer.DisplayName.class) +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public abstract class DatabaseTestConfig { + + private static final Logger log = LoggerFactory.getLogger(DatabaseTestConfig.class); + private static final String MYSQL_IMAGE = "mysql:8.0"; + + @ServiceConnection + static final MySQLContainer MYSQL_CONTAINER; + + static { + MYSQL_CONTAINER = new MySQLContainer<>(MYSQL_IMAGE) + .withCommand("--default-time-zone=+09:00") + .withLogConsumer(new Slf4jLogConsumer(log)); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername); + registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword); + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 3dd7d5d3..fe533a01 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,2 +1,39 @@ server: port: 8091 + +spring: + profiles: + active: test + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + show-sql: true + data: + redis: + host: localhost + port: 6379 + +jwt: + secret: test-secret-key-for-testing-purpose-only-should-be-256-bits-long + expiration: + access: 1800000 + refresh: 1209600000 + register: 300000 + redirect: + access: "http://localhost:3000/auth?is_member=%s&access_token=%s&refresh_token=%s" + register: "http://localhost:3000/auth?is_member=false®ister_token=%s&name=%s" + +refresh-token: + cleanup: + update-expired-cron: "0 0 3 * * *" + hard-delete-cron: "0 30 3 * * *" + retention-days: 30 + +test: + auth: + enabled: true + secret-key: test-api-secret-key From edc350b6a3f86d3e9b2904a7bd38c87ee4efaf7b Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:40:17 +0900 Subject: [PATCH 08/30] =?UTF-8?q?[test]=20:=20RefreshToken=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshTokenRepositoryTest: Repository 레이어 통합 테스트 - TokenServiceTest: 토큰 재발급, 검증, 토큰 탈취 감지 테스트 - TokenControllerTest: API 엔드포인트 테스트 업데이트 - UserControllerTest: HttpServletRequest 목 추가 Co-Authored-By: Claude Opus 4.5 --- .../token/RefreshTokenRepositoryTest.java | 287 ++++++++++++++++++ .../onetime/token/TokenControllerTest.java | 24 +- .../side/onetime/token/TokenServiceTest.java | 269 ++++++++++++++++ .../side/onetime/user/UserControllerTest.java | 37 ++- 4 files changed, 592 insertions(+), 25 deletions(-) create mode 100644 src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java create mode 100644 src/test/java/side/onetime/token/TokenServiceTest.java diff --git a/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java new file mode 100644 index 00000000..028b3882 --- /dev/null +++ b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java @@ -0,0 +1,287 @@ +package side.onetime.token; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import jakarta.persistence.EntityManager; +import side.onetime.configuration.DatabaseTestConfig; +import side.onetime.domain.RefreshToken; +import side.onetime.domain.enums.TokenStatus; +import side.onetime.global.config.QueryDslConfig; +import side.onetime.repository.RefreshTokenRepository; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) +@DisplayName("RefreshTokenRepository 테스트") +class RefreshTokenRepositoryTest extends DatabaseTestConfig { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private EntityManager entityManager; + + private static final Long TEST_USER_ID = 1L; + private static final String TEST_BROWSER_ID = "browser-hash-123"; + private static final String TEST_USER_IP = "127.0.0.1"; + private static final String TEST_USER_AGENT = "Mozilla/5.0"; + + private RefreshToken createAndSaveToken(String jti) { + RefreshToken token = RefreshToken.create( + TEST_USER_ID, jti, TEST_BROWSER_ID, "token-value-" + jti, + LocalDateTime.now(), LocalDateTime.now().plusDays(14), + TEST_USER_IP, TEST_USER_AGENT + ); + return refreshTokenRepository.saveAndFlush(token); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + @Nested + @DisplayName("findByJti 메서드") + class FindByJti { + + @Test + @DisplayName("jti로 토큰 조회 성공") + void findByJti_Success() { + // given + String jti = "test-jti-1"; + createAndSaveToken(jti); + flushAndClear(); + + // when + Optional found = refreshTokenRepository.findByJti(jti); + + // then + assertThat(found).isPresent(); + assertThat(found.get().getJti()).isEqualTo(jti); + assertThat(found.get().getUserId()).isEqualTo(TEST_USER_ID); + } + + @Test + @DisplayName("존재하지 않는 jti 조회 시 빈 Optional 반환") + void findByJti_NotFound() { + // when + Optional found = refreshTokenRepository.findByJti("non-existent-jti"); + + // then + assertThat(found).isEmpty(); + } + } + + @Nested + @DisplayName("markAsRotatedIfActive 메서드 (원자적 업데이트)") + class MarkAsRotatedIfActive { + + @Test + @DisplayName("ACTIVE 토큰 ROTATED로 변경 성공") + void markAsRotatedIfActive_Success() { + // given + String jti = "active-token-jti"; + RefreshToken activeToken = createAndSaveToken(jti); + Long tokenId = activeToken.getId(); + LocalDateTime lastUsedAt = LocalDateTime.now(); + String lastUsedIp = "192.168.1.1"; + flushAndClear(); + + // when + int updated = refreshTokenRepository.markAsRotatedIfActive(tokenId, lastUsedAt, lastUsedIp); + flushAndClear(); + + // then + assertThat(updated).isEqualTo(1); + + RefreshToken updatedToken = refreshTokenRepository.findById(tokenId).orElseThrow(); + assertThat(updatedToken.getStatus()).isEqualTo(TokenStatus.ROTATED); + assertThat(updatedToken.getLastUsedIp()).isEqualTo(lastUsedIp); + } + + @Test + @DisplayName("이미 ROTATED된 토큰은 업데이트되지 않음") + void markAsRotatedIfActive_AlreadyRotated() { + // given + String jti = "rotated-token-jti"; + RefreshToken token = createAndSaveToken(jti); + Long tokenId = token.getId(); + flushAndClear(); + + // First rotation + refreshTokenRepository.markAsRotatedIfActive(tokenId, LocalDateTime.now(), "first-ip"); + flushAndClear(); + + // when - try to rotate again + int updated = refreshTokenRepository.markAsRotatedIfActive(tokenId, LocalDateTime.now(), "second-ip"); + flushAndClear(); + + // then + assertThat(updated).isEqualTo(0); + + RefreshToken found = refreshTokenRepository.findById(tokenId).orElseThrow(); + assertThat(found.getLastUsedIp()).isEqualTo("first-ip"); + } + } + + @Nested + @DisplayName("revokeByUserIdAndBrowserId 메서드") + class RevokeByUserIdAndBrowserId { + + @Test + @DisplayName("특정 유저+브라우저의 ACTIVE 토큰 revoke") + void revokeByUserIdAndBrowserId_Success() { + // given + RefreshToken token = createAndSaveToken("revoke-test-jti"); + Long tokenId = token.getId(); + flushAndClear(); + + // when + refreshTokenRepository.revokeByUserIdAndBrowserId(TEST_USER_ID, TEST_BROWSER_ID); + flushAndClear(); + + // then + RefreshToken found = refreshTokenRepository.findById(tokenId).orElseThrow(); + assertThat(found.getStatus()).isEqualTo(TokenStatus.REVOKED); + } + } + + @Nested + @DisplayName("revokeAllByUserId 메서드") + class RevokeAllByUserId { + + @Test + @DisplayName("특정 유저의 모든 ACTIVE 토큰 revoke") + void revokeAllByUserId_Success() { + // given + RefreshToken token1 = createAndSaveToken("user-token-1"); + RefreshToken token2 = createAndSaveToken("user-token-2"); + Long token1Id = token1.getId(); + Long token2Id = token2.getId(); + flushAndClear(); + + // when + refreshTokenRepository.revokeAllByUserId(TEST_USER_ID); + flushAndClear(); + + // then + RefreshToken found1 = refreshTokenRepository.findById(token1Id).orElseThrow(); + RefreshToken found2 = refreshTokenRepository.findById(token2Id).orElseThrow(); + assertThat(found1.getStatus()).isEqualTo(TokenStatus.REVOKED); + assertThat(found2.getStatus()).isEqualTo(TokenStatus.REVOKED); + } + } + + @Nested + @DisplayName("revokeAllByFamilyId 메서드") + class RevokeAllByFamilyId { + + @Test + @DisplayName("특정 family의 모든 ACTIVE/ROTATED 토큰 revoke") + void revokeAllByFamilyId_Success() { + // given + RefreshToken parentToken = createAndSaveToken("family-parent"); + String familyId = parentToken.getFamilyId(); + Long parentId = parentToken.getId(); + + // Create a child token in the same family (simulating rotation) + RefreshToken childToken = parentToken.rotate( + "family-child", "child-token-value", + LocalDateTime.now(), LocalDateTime.now().plusDays(14), + TEST_USER_IP, TEST_USER_AGENT + ); + childToken = refreshTokenRepository.saveAndFlush(childToken); + Long childId = childToken.getId(); + + // Mark parent as rotated + refreshTokenRepository.markAsRotatedIfActive(parentId, LocalDateTime.now(), TEST_USER_IP); + flushAndClear(); + + // when + refreshTokenRepository.revokeAllByFamilyId(familyId); + flushAndClear(); + + // then + RefreshToken foundParent = refreshTokenRepository.findById(parentId).orElseThrow(); + RefreshToken foundChild = refreshTokenRepository.findById(childId).orElseThrow(); + assertThat(foundParent.getStatus()).isEqualTo(TokenStatus.REVOKED); + assertThat(foundChild.getStatus()).isEqualTo(TokenStatus.REVOKED); + } + } + + @Nested + @DisplayName("updateExpiredTokens 메서드") + class UpdateExpiredTokens { + + @Test + @DisplayName("만료된 ACTIVE 토큰을 EXPIRED로 변경") + void updateExpiredTokens_Success() { + // given + RefreshToken expiredToken = RefreshToken.create( + TEST_USER_ID, "expired-jti", TEST_BROWSER_ID, "expired-token-value", + LocalDateTime.now().minusDays(15), LocalDateTime.now().minusDays(1), + TEST_USER_IP, TEST_USER_AGENT + ); + expiredToken = refreshTokenRepository.saveAndFlush(expiredToken); + Long expiredId = expiredToken.getId(); + + RefreshToken validToken = RefreshToken.create( + TEST_USER_ID, "valid-jti", TEST_BROWSER_ID, "valid-token-value", + LocalDateTime.now(), LocalDateTime.now().plusDays(14), + TEST_USER_IP, TEST_USER_AGENT + ); + validToken = refreshTokenRepository.saveAndFlush(validToken); + Long validId = validToken.getId(); + flushAndClear(); + + // when + int updated = refreshTokenRepository.updateExpiredTokens(LocalDateTime.now()); + flushAndClear(); + + // then + assertThat(updated).isEqualTo(1); + RefreshToken foundExpired = refreshTokenRepository.findById(expiredId).orElseThrow(); + RefreshToken foundValid = refreshTokenRepository.findById(validId).orElseThrow(); + assertThat(foundExpired.getStatus()).isEqualTo(TokenStatus.EXPIRED); + assertThat(foundValid.getStatus()).isEqualTo(TokenStatus.ACTIVE); + } + } + + @Nested + @DisplayName("hardDeleteOldInactiveTokens 메서드") + class HardDeleteOldInactiveTokens { + + @Test + @DisplayName("오래된 비활성 토큰 물리적 삭제") + void hardDeleteOldInactiveTokens_Success() { + // given + RefreshToken token = createAndSaveToken("to-delete-jti"); + Long tokenId = token.getId(); + flushAndClear(); + + // Mark as rotated first + refreshTokenRepository.markAsRotatedIfActive(tokenId, LocalDateTime.now(), TEST_USER_IP); + flushAndClear(); + + // when - threshold in future to match all ROTATED tokens + LocalDateTime threshold = LocalDateTime.now().plusDays(1); + int deleted = refreshTokenRepository.hardDeleteOldInactiveTokens(threshold); + flushAndClear(); + + // then + assertThat(deleted).isEqualTo(1); + assertThat(refreshTokenRepository.findById(tokenId)).isEmpty(); + } + } +} diff --git a/src/test/java/side/onetime/token/TokenControllerTest.java b/src/test/java/side/onetime/token/TokenControllerTest.java index 148191da..1b1dc739 100644 --- a/src/test/java/side/onetime/token/TokenControllerTest.java +++ b/src/test/java/side/onetime/token/TokenControllerTest.java @@ -1,8 +1,11 @@ package side.onetime.token; -import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import com.epages.restdocs.apispec.ResourceSnippetParameters; -import com.fasterxml.jackson.databind.ObjectMapper; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -12,19 +15,18 @@ import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.web.servlet.ResultActions; + +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; import side.onetime.configuration.ControllerTestConfig; import side.onetime.controller.TokenController; import side.onetime.dto.token.request.ReissueTokenRequest; import side.onetime.dto.token.response.ReissueTokenResponse; import side.onetime.service.TokenService; -import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(TokenController.class) public class TokenControllerTest extends ControllerTestConfig { @@ -40,7 +42,7 @@ public void reissueTokenSuccess() throws Exception { String newRefreshToken = "newRefreshToken"; ReissueTokenResponse response = ReissueTokenResponse.of(newAccessToken, newRefreshToken); - Mockito.when(tokenService.reissueToken(any(ReissueTokenRequest.class))) + Mockito.when(tokenService.reissueToken(any(ReissueTokenRequest.class), any(HttpServletRequest.class))) .thenReturn(response); ReissueTokenRequest request = new ReissueTokenRequest(oldRefreshToken); diff --git a/src/test/java/side/onetime/token/TokenServiceTest.java b/src/test/java/side/onetime/token/TokenServiceTest.java new file mode 100644 index 00000000..b66a636e --- /dev/null +++ b/src/test/java/side/onetime/token/TokenServiceTest.java @@ -0,0 +1,269 @@ +package side.onetime.token; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.servlet.http.HttpServletRequest; +import side.onetime.domain.RefreshToken; +import side.onetime.domain.enums.TokenStatus; +import side.onetime.dto.token.request.ReissueTokenRequest; +import side.onetime.dto.token.response.ReissueTokenResponse; +import side.onetime.exception.CustomException; +import side.onetime.exception.status.TokenErrorStatus; +import side.onetime.repository.RefreshTokenRepository; +import side.onetime.service.TokenService; +import side.onetime.util.ClientInfoExtractor; +import side.onetime.util.JwtUtil; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenService 테스트") +class TokenServiceTest { + + @InjectMocks + private TokenService tokenService; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtUtil jwtUtil; + + @Mock + private ClientInfoExtractor clientInfoExtractor; + + @Mock + private HttpServletRequest httpRequest; + + private static final String TEST_JTI = "test-jti-uuid"; + private static final String TEST_REFRESH_TOKEN = "test.refresh.token"; + private static final String TEST_NEW_ACCESS_TOKEN = "new.access.token"; + private static final String TEST_NEW_REFRESH_TOKEN = "new.refresh.token"; + private static final String TEST_USER_IP = "127.0.0.1"; + private static final String TEST_USER_AGENT = "Mozilla/5.0"; + private static final String TEST_BROWSER_ID = "browser-hash-123"; + private static final Long TEST_USER_ID = 1L; + + @BeforeEach + void setUp() { + // Common mock setup + given(jwtUtil.getClaimFromToken(TEST_REFRESH_TOKEN, "jti", String.class)).willReturn(TEST_JTI); + given(clientInfoExtractor.extractClientIp(httpRequest)).willReturn(TEST_USER_IP); + given(clientInfoExtractor.extractUserAgent(httpRequest)).willReturn(TEST_USER_AGENT); + } + + private RefreshToken createTestToken(TokenStatus status, LocalDateTime lastUsedAt) { + RefreshToken token = RefreshToken.create( + TEST_USER_ID, TEST_JTI, TEST_BROWSER_ID, TEST_REFRESH_TOKEN, + LocalDateTime.now(), LocalDateTime.now().plusDays(14), + TEST_USER_IP, TEST_USER_AGENT + ); + + // Use reflection to set status and lastUsedAt for testing + try { + Field statusField = RefreshToken.class.getDeclaredField("status"); + statusField.setAccessible(true); + statusField.set(token, status); + + if (lastUsedAt != null) { + Field lastUsedAtField = RefreshToken.class.getDeclaredField("lastUsedAt"); + lastUsedAtField.setAccessible(true); + lastUsedAtField.set(token, lastUsedAt); + } + + Field idField = RefreshToken.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(token, 1L); + } catch (Exception e) { + throw new RuntimeException("Failed to set test token fields", e); + } + + return token; + } + + @Nested + @DisplayName("reissueToken 메서드") + class ReissueToken { + + @Test + @DisplayName("ACTIVE 토큰으로 재발급 성공") + void reissueToken_Success_WithActiveToken() { + // given + RefreshToken activeToken = createTestToken(TokenStatus.ACTIVE, null); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(activeToken)); + given(refreshTokenRepository.markAsRotatedIfActive(eq(1L), any(LocalDateTime.class), eq(TEST_USER_IP))) + .willReturn(1); + given(jwtUtil.generateAccessToken(TEST_USER_ID, "USER")).willReturn(TEST_NEW_ACCESS_TOKEN); + given(jwtUtil.generateRefreshToken(eq(TEST_USER_ID), eq(TEST_BROWSER_ID), anyString())) + .willReturn(TEST_NEW_REFRESH_TOKEN); + given(jwtUtil.calculateRefreshTokenExpiryAt(any(LocalDateTime.class))) + .willReturn(LocalDateTime.now().plusDays(14)); + + // when + ReissueTokenResponse response = tokenService.reissueToken(request, httpRequest); + + // then + assertThat(response.accessToken()).isEqualTo(TEST_NEW_ACCESS_TOKEN); + assertThat(response.refreshToken()).isEqualTo(TEST_NEW_REFRESH_TOKEN); + verify(refreshTokenRepository).markAsRotatedIfActive(eq(1L), any(LocalDateTime.class), eq(TEST_USER_IP)); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("토큰 값 불일치 시 실패") + void reissueToken_Fail_TokenValueMismatch() { + // given + RefreshToken token = createTestToken(TokenStatus.ACTIVE, null); + // Change tokenValue to simulate mismatch + try { + Field tokenValueField = RefreshToken.class.getDeclaredField("tokenValue"); + tokenValueField.setAccessible(true); + tokenValueField.set(token, "different.token.value"); + } catch (Exception e) { + throw new RuntimeException(e); + } + + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(token)); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._INVALID_REFRESH_TOKEN); + }); + } + + @Test + @DisplayName("ROTATED 토큰 - Grace Period 내 중복 요청") + void reissueToken_Fail_DuplicatedRequestWithinGracePeriod() { + // given + LocalDateTime withinGracePeriod = LocalDateTime.now().minusSeconds(1); // 1초 전 (3초 Grace Period 내) + RefreshToken rotatedToken = createTestToken(TokenStatus.ROTATED, withinGracePeriod); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(rotatedToken)); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._DUPLICATED_REQUEST); + }); + } + + @Test + @DisplayName("ROTATED 토큰 - Grace Period 초과 시 공격 탐지") + void reissueToken_Fail_TokenReuseDetected() { + // given + LocalDateTime outsideGracePeriod = LocalDateTime.now().minusSeconds(10); // 10초 전 (Grace Period 초과) + RefreshToken rotatedToken = createTestToken(TokenStatus.ROTATED, outsideGracePeriod); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(rotatedToken)); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._TOKEN_REUSE_DETECTED); + }); + + verify(refreshTokenRepository).revokeAllByFamilyId(rotatedToken.getFamilyId()); + } + + @Test + @DisplayName("REVOKED 토큰으로 재발급 시도 시 실패") + void reissueToken_Fail_WithRevokedToken() { + // given + RefreshToken revokedToken = createTestToken(TokenStatus.REVOKED, null); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(revokedToken)); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._INVALID_REFRESH_TOKEN); + }); + } + + @Test + @DisplayName("EXPIRED 토큰으로 재발급 시도 시 실패") + void reissueToken_Fail_WithExpiredToken() { + // given + RefreshToken expiredToken = createTestToken(TokenStatus.EXPIRED, null); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(expiredToken)); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._INVALID_REFRESH_TOKEN); + }); + } + + @Test + @DisplayName("존재하지 않는 토큰으로 재발급 시도 시 실패") + void reissueToken_Fail_TokenNotFound() { + // given + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._NOT_FOUND_REFRESH_TOKEN); + }); + } + + @Test + @DisplayName("원자적 업데이트 실패 시 (Race Condition) 에러") + void reissueToken_Fail_RaceCondition() { + // given + RefreshToken activeToken = createTestToken(TokenStatus.ACTIVE, null); + ReissueTokenRequest request = new ReissueTokenRequest(TEST_REFRESH_TOKEN); + + given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(activeToken)); + // 원자적 업데이트가 0을 반환 (이미 다른 요청에서 rotate됨) + given(refreshTokenRepository.markAsRotatedIfActive(eq(1L), any(LocalDateTime.class), eq(TEST_USER_IP))) + .willReturn(0); + + // when & then + assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> { + CustomException customEx = (CustomException) ex; + assertThat(customEx.getErrorCode()).isEqualTo(TokenErrorStatus._ALREADY_USED_REFRESH_TOKEN); + }); + + // 새 토큰이 생성되지 않았는지 확인 + verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); + } + } +} diff --git a/src/test/java/side/onetime/user/UserControllerTest.java b/src/test/java/side/onetime/user/UserControllerTest.java index 6781e4de..56516a67 100644 --- a/src/test/java/side/onetime/user/UserControllerTest.java +++ b/src/test/java/side/onetime/user/UserControllerTest.java @@ -1,8 +1,11 @@ package side.onetime.user; -import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; -import com.epages.restdocs.apispec.ResourceSnippetParameters; -import com.epages.restdocs.apispec.Schema; +import static com.epages.restdocs.apispec.ResourceDocumentation.*; +import static org.mockito.ArgumentMatchers.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -12,24 +15,30 @@ import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.test.web.servlet.ResultActions; + +import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.epages.restdocs.apispec.Schema; + +import jakarta.servlet.http.HttpServletRequest; import side.onetime.configuration.UserControllerTestConfig; import side.onetime.controller.UserController; import side.onetime.domain.enums.GuideType; import side.onetime.domain.enums.Language; -import side.onetime.dto.user.request.*; -import side.onetime.dto.user.response.*; +import side.onetime.dto.user.request.CreateGuideViewLogRequest; +import side.onetime.dto.user.request.OnboardUserRequest; +import side.onetime.dto.user.request.UpdateUserPolicyAgreementRequest; +import side.onetime.dto.user.request.UpdateUserProfileRequest; +import side.onetime.dto.user.request.UpdateUserSleepTimeRequest; +import side.onetime.dto.user.response.GetGuideViewLogResponse; +import side.onetime.dto.user.response.GetUserPolicyAgreementResponse; +import side.onetime.dto.user.response.GetUserProfileResponse; +import side.onetime.dto.user.response.GetUserSleepTimeResponse; +import side.onetime.dto.user.response.OnboardUserResponse; import side.onetime.exception.CustomException; import side.onetime.exception.status.UserErrorStatus; import side.onetime.service.UserService; -import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; -import static com.epages.restdocs.apispec.ResourceDocumentation.resource; -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; -import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(UserController.class) public class UserControllerTest extends UserControllerTestConfig { @@ -41,7 +50,7 @@ public class UserControllerTest extends UserControllerTestConfig { public void onboardUser() throws Exception { // given OnboardUserResponse response = new OnboardUserResponse("sampleAccessToken", "sampleRefreshToken"); - Mockito.when(userService.onboardUser(any(OnboardUserRequest.class))).thenReturn(response); + Mockito.when(userService.onboardUser(any(OnboardUserRequest.class), any(HttpServletRequest.class))).thenReturn(response); OnboardUserRequest request = new OnboardUserRequest( "sampleRegisterToken", From 548c462456027f35fe41ed14a8c787f0fdcb111e Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:40:31 +0900 Subject: [PATCH 09/30] =?UTF-8?q?[ci]=20:=20commit-labeler=EC=97=90=20ci?= =?UTF-8?q?=20=EB=9D=BC=EB=B2=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .github/workflows/commit-labeler.yaml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/commit-labeler.yaml b/.github/workflows/commit-labeler.yaml index 53bd1827..8357bd49 100644 --- a/.github/workflows/commit-labeler.yaml +++ b/.github/workflows/commit-labeler.yaml @@ -21,16 +21,19 @@ jobs: for (const c of commits.data) { const msg = c.commit.message.toLowerCase(); - if (msg.includes("[feat]")) labels.add("🚀 feat"); - if (msg.includes("[fix]")) labels.add("🚨 fix"); - if (msg.includes("[docs]")) labels.add("📄 docs"); - if (msg.includes("[style]")) labels.add("🌱 style"); - if (msg.includes("[refactor]")) labels.add("🔄 refactor"); - if (msg.includes("[chore]")) labels.add("⚒️ chore"); - if (msg.includes("[hotfix]")) labels.add("🛟 hotfix"); - if (msg.includes("[release]")) labels.add("💫 release"); - if (msg.includes("[rename]")) labels.add("🎫 rename"); - if (msg.includes("[remove]")) labels.add("✂️ remove"); + if (msg.includes("feat")) labels.add("🚀 feat"); + if (msg.includes("fix")) labels.add("🚨 fix"); + if (msg.includes("docs")) labels.add("📄 docs"); + if (msg.includes("style")) labels.add("🌱 style"); + if (msg.includes("refactor")) labels.add("🔄 refactor"); + if (msg.includes("test")) labels.add("✅ test"); + if (msg.includes("perf")) labels.add("⚡️ perf"); + if (msg.includes("chore")) labels.add("⚒️ chore"); + if (msg.includes("ci")) labels.add("🔧 ci"); + if (msg.includes("hotfix")) labels.add("🛟 hotfix"); + if (msg.includes("release")) labels.add("💫 release"); + if (msg.includes("rename")) labels.add("🎫 rename"); + if (msg.includes("remove")) labels.add("✂️ remove"); } if (labels.size > 0) { From 8debc82d4fc9c9bdb0da0ad90798114c4de07fc7 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:40:45 +0900 Subject: [PATCH 10/30] =?UTF-8?q?[ci]=20:=20prod-cicd=20PR=20=EB=A8=B8?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=EC=97=90=EB=A7=8C=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .github/workflows/prod-cicd.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/prod-cicd.yaml b/.github/workflows/prod-cicd.yaml index f8bfa58e..5c85e991 100644 --- a/.github/workflows/prod-cicd.yaml +++ b/.github/workflows/prod-cicd.yaml @@ -11,6 +11,7 @@ permissions: jobs: build-and-push: name: Build and Push to ECR + if: github.event.pull_request.merged == true runs-on: ubuntu-latest environment: prod steps: From f9c2d218e1410b51dcb916a21cb8a41c04ef1646 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 14:41:00 +0900 Subject: [PATCH 11/30] =?UTF-8?q?[chore]=20:=20open-api=20JSON=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20gitignore=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5c184bde..d57617e2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ out/ ### Claude Code ### .claude /docs + +### Auto-generated files ### +src/main/resources/static/docs/open-api-3.0.1.json From d98a80999bf62545b49a9163a64ca035f5558289 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:10:08 +0900 Subject: [PATCH 12/30] =?UTF-8?q?[ci]=20:=20PR=20Auto=20Assign=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PR 생성 시 작성자를 assignee로 자동 할당 - 팀원을 reviewer로 자동 할당 (작성자 제외) - 설정 파일명 변경: auto_assign.yaml → auto-assign-config.yaml Co-Authored-By: Claude Opus 4.5 --- .../{auto_assign.yaml => auto-assign-config.yaml} | 3 ++- .github/workflows/auto-assign.yaml | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) rename .github/{auto_assign.yaml => auto-assign-config.yaml} (67%) create mode 100644 .github/workflows/auto-assign.yaml diff --git a/.github/auto_assign.yaml b/.github/auto-assign-config.yaml similarity index 67% rename from .github/auto_assign.yaml rename to .github/auto-assign-config.yaml index 0a399c69..9a878556 100644 --- a/.github/auto_assign.yaml +++ b/.github/auto-assign-config.yaml @@ -1,4 +1,5 @@ addReviewers: true addAssignees: author reviewers: - - bbang105 + - bbbang105 + - anxi01 diff --git a/.github/workflows/auto-assign.yaml b/.github/workflows/auto-assign.yaml new file mode 100644 index 00000000..d9a9ce3e --- /dev/null +++ b/.github/workflows/auto-assign.yaml @@ -0,0 +1,15 @@ +name: Auto Assign + +on: + pull_request: + types: [opened, ready_for_review] + +jobs: + assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: kentaro-m/auto-assign-action@v2.0.1 + with: + configuration-path: '.github/auto-assign-config.yaml' From 4a881a402fc187c2c4355aabc1ad8c62d2cb1f28 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:12:09 +0900 Subject: [PATCH 13/30] =?UTF-8?q?[chore]=20:=20CODEOWNERS=EC=97=90=20?= =?UTF-8?q?=ED=8C=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef290170..87149cc7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bbbang105 +* @bbbang105 @anxi01 From bbafaa014b3fac3e594f98d9ef834b433d605763 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:13:51 +0900 Subject: [PATCH 14/30] =?UTF-8?q?[chore]=20:=20static/docs=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20gitkeep=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI에서 openapi3 태스크 실행 시 디렉토리 없음 오류 방지 Co-Authored-By: Claude Opus 4.5 --- src/main/resources/static/docs/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/static/docs/.gitkeep diff --git a/src/main/resources/static/docs/.gitkeep b/src/main/resources/static/docs/.gitkeep new file mode 100644 index 00000000..e69de29b From 49bcdb413edff0aaea4b20f740b9ddcee9352539 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:38:51 +0900 Subject: [PATCH 15/30] =?UTF-8?q?fix:=20RefreshToken=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20@Transactional=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RefreshTokenRepositoryImpl의 update/delete 메서드에 @Transactional 추가 - TestAuthService.login()에 @Transactional 추가 - Security Filter에서 호출 시 트랜잭션 없이 실행되는 문제 해결 Co-Authored-By: Claude Opus 4.5 --- .../repository/custom/RefreshTokenRepositoryImpl.java | 7 +++++++ src/main/java/side/onetime/service/TestAuthService.java | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java index 07d2f09a..308fc9ea 100644 --- a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java @@ -4,6 +4,8 @@ import java.time.LocalDateTime; +import org.springframework.transaction.annotation.Transactional; + import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -15,6 +17,7 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom private final JPAQueryFactory queryFactory; @Override + @Transactional public void revokeByUserIdAndBrowserId(Long userId, String browserId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) @@ -25,6 +28,7 @@ public void revokeByUserIdAndBrowserId(Long userId, String browserId) { } @Override + @Transactional public void revokeAllByUserId(Long userId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) @@ -34,6 +38,7 @@ public void revokeAllByUserId(Long userId) { } @Override + @Transactional public void revokeAllByFamilyId(String familyId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) @@ -43,6 +48,7 @@ public void revokeAllByFamilyId(String familyId) { } @Override + @Transactional public int updateExpiredTokens(LocalDateTime now) { return (int) queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.EXPIRED) @@ -52,6 +58,7 @@ public int updateExpiredTokens(LocalDateTime now) { } @Override + @Transactional public int hardDeleteOldInactiveTokens(LocalDateTime threshold) { return (int) queryFactory.delete(refreshToken) .where(refreshToken.status.in(TokenStatus.REVOKED, TokenStatus.EXPIRED, TokenStatus.ROTATED) diff --git a/src/main/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index 693e22c8..a74b3c92 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import side.onetime.domain.RefreshToken; @@ -38,6 +39,7 @@ public class TestAuthService { * @param request 테스트 로그인 요청 (시크릿 키 포함) * @return Access Token과 Refresh Token을 포함하는 응답 객체 */ + @Transactional public OnboardUserResponse login(TestLoginRequest request) { // 1. 시크릿 키 검증 if (!testSecretKey.equals(request.secretKey())) { From 73246078fe6550e86b620c310ba8b45461f7f505 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:42:50 +0900 Subject: [PATCH 16/30] =?UTF-8?q?fix:=20QueryDSL=20bulk=20update=20?= =?UTF-8?q?=EC=8B=9C=20updatedDate=20=EC=88=98=EB=8F=99=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JPA Auditing이 bulk update를 우회하므로 updatedDate 수동 설정 - hardDeleteOldInactiveTokens가 updatedDate 기준으로 삭제하므로 필수 Co-Authored-By: Claude Opus 4.5 --- .../onetime/repository/custom/RefreshTokenRepositoryImpl.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java index 308fc9ea..eae522b4 100644 --- a/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java +++ b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java @@ -21,6 +21,7 @@ public class RefreshTokenRepositoryImpl implements RefreshTokenRepositoryCustom public void revokeByUserIdAndBrowserId(Long userId, String browserId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) + .set(refreshToken.updatedDate, LocalDateTime.now()) .where(refreshToken.userId.eq(userId) .and(refreshToken.browserId.eq(browserId)) .and(refreshToken.status.eq(TokenStatus.ACTIVE))) @@ -32,6 +33,7 @@ public void revokeByUserIdAndBrowserId(Long userId, String browserId) { public void revokeAllByUserId(Long userId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) + .set(refreshToken.updatedDate, LocalDateTime.now()) .where(refreshToken.userId.eq(userId) .and(refreshToken.status.eq(TokenStatus.ACTIVE))) .execute(); @@ -42,6 +44,7 @@ public void revokeAllByUserId(Long userId) { public void revokeAllByFamilyId(String familyId) { queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.REVOKED) + .set(refreshToken.updatedDate, LocalDateTime.now()) .where(refreshToken.familyId.eq(familyId) .and(refreshToken.status.in(TokenStatus.ACTIVE, TokenStatus.ROTATED))) .execute(); @@ -52,6 +55,7 @@ public void revokeAllByFamilyId(String familyId) { public int updateExpiredTokens(LocalDateTime now) { return (int) queryFactory.update(refreshToken) .set(refreshToken.status, TokenStatus.EXPIRED) + .set(refreshToken.updatedDate, now) .where(refreshToken.status.eq(TokenStatus.ACTIVE) .and(refreshToken.expiryAt.lt(now))) .execute(); From ca59967f09b58d9579c2dc7379cdf1d2341d3150 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 16:54:45 +0900 Subject: [PATCH 17/30] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=EC=97=90=EC=84=9C=20JwtFilter=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/v1/tokens/action-reissue는 만료된 액세스 토큰으로 요청 - JwtFilter.shouldNotFilter()에 해당 경로 추가 Co-Authored-By: Claude Opus 4.5 --- src/main/java/side/onetime/global/filter/JwtFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/side/onetime/global/filter/JwtFilter.java b/src/main/java/side/onetime/global/filter/JwtFilter.java index 30f80bdb..24407707 100644 --- a/src/main/java/side/onetime/global/filter/JwtFilter.java +++ b/src/main/java/side/onetime/global/filter/JwtFilter.java @@ -100,7 +100,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { path.equals("/") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || - path.startsWith("/favicon.ico"); + path.startsWith("/favicon.ico") || + path.equals("/api/v1/tokens/action-reissue"); } /** From a3f7aba5a57d35fce3a1f3951a6600800b53d498 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 20 Jan 2026 17:13:11 +0900 Subject: [PATCH 18/30] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=EC=97=90=EC=84=9C=20JwtFilter=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/v1/users/logout도 만료된 액세스 토큰으로 요청 가능 - shouldNotFilter에 해당 경로 추가 Co-Authored-By: Claude Opus 4.5 --- src/main/java/side/onetime/global/filter/JwtFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/side/onetime/global/filter/JwtFilter.java b/src/main/java/side/onetime/global/filter/JwtFilter.java index 24407707..607bb714 100644 --- a/src/main/java/side/onetime/global/filter/JwtFilter.java +++ b/src/main/java/side/onetime/global/filter/JwtFilter.java @@ -101,7 +101,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.startsWith("/favicon.ico") || - path.equals("/api/v1/tokens/action-reissue"); + path.equals("/api/v1/tokens/action-reissue") || + path.equals("/api/v1/users/logout"); } /** From 2b8bafc562022ec2cf27e9d18b08fca4d640e1f6 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 10:31:07 +0900 Subject: [PATCH 19/30] =?UTF-8?q?refactor:=20LocalDateTime.now()=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=ED=98=B8=EC=B6=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestAuthService에서 now 변수 재사용하도록 수정 - 미세한 시간 차이로 인한 테스트/로깅 불일치 방지 Co-Authored-By: Claude Opus 4.5 --- src/main/java/side/onetime/service/TestAuthService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index a74b3c92..769bcb32 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -58,7 +58,7 @@ public OnboardUserResponse login(TestLoginRequest request) { String refreshTokenValue = jwtUtil.generateRefreshToken(testUserId, browserId, jti); LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(LocalDateTime.now()); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); // 3. Refresh Token MySQL 저장 RefreshToken refreshToken = RefreshToken.create( From c2412b31e3f4adccd733dda6bbff71059c12fc30 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 12:04:52 +0900 Subject: [PATCH 20/30] =?UTF-8?q?fix:=20TokenService=EC=97=90=20validateTo?= =?UTF-8?q?ken()=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getClaimFromToken() 호출 전 토큰 검증 추가 - 만료된 토큰에 대해 명확한 에러 메시지 반환 Co-Authored-By: Claude Opus 4.5 --- src/main/java/side/onetime/service/TokenService.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/side/onetime/service/TokenService.java b/src/main/java/side/onetime/service/TokenService.java index 92cb7184..d53b13be 100644 --- a/src/main/java/side/onetime/service/TokenService.java +++ b/src/main/java/side/onetime/service/TokenService.java @@ -19,11 +19,6 @@ import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; -/** - * 토큰 서비스 - * - * Token Rotation + Grace Period를 적용한 Refresh Token 재발급 처리 - */ @Slf4j @Service @RequiredArgsConstructor @@ -53,6 +48,7 @@ public class TokenService { public ReissueTokenResponse reissueToken(ReissueTokenRequest reissueTokenRequest, HttpServletRequest httpRequest) { String refreshToken = reissueTokenRequest.refreshToken(); + jwtUtil.validateToken(refreshToken); String jti = jwtUtil.getClaimFromToken(refreshToken, "jti", String.class); String userIp = clientInfoExtractor.extractClientIp(httpRequest); String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); From 9bad834e17e7bd8e750208103e7b01ff324ac313 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 12:06:22 +0900 Subject: [PATCH 21/30] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20Di?= =?UTF-8?q?stributedLock=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DistributedLock 어노테이션 제거 - DistributedLockAop 제거 - CustomSpringELParser 제거 Co-Authored-By: Claude Opus 4.5 --- .../lock/annotation/DistributedLock.java | 17 ------- .../global/lock/aop/DistributedLockAop.java | 50 ------------------- .../lock/util/CustomSpringELParser.java | 29 ----------- 3 files changed, 96 deletions(-) delete mode 100644 src/main/java/side/onetime/global/lock/annotation/DistributedLock.java delete mode 100644 src/main/java/side/onetime/global/lock/aop/DistributedLockAop.java delete mode 100644 src/main/java/side/onetime/global/lock/util/CustomSpringELParser.java diff --git a/src/main/java/side/onetime/global/lock/annotation/DistributedLock.java b/src/main/java/side/onetime/global/lock/annotation/DistributedLock.java deleted file mode 100644 index dad65fe3..00000000 --- a/src/main/java/side/onetime/global/lock/annotation/DistributedLock.java +++ /dev/null @@ -1,17 +0,0 @@ -package side.onetime.global.lock.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; -import java.util.concurrent.TimeUnit; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface DistributedLock { - String prefix(); - String key(); - long waitTime() default 2L; - long leaseTime() default 3L; - TimeUnit timeUnit() default TimeUnit.SECONDS; -} diff --git a/src/main/java/side/onetime/global/lock/aop/DistributedLockAop.java b/src/main/java/side/onetime/global/lock/aop/DistributedLockAop.java deleted file mode 100644 index a7e873db..00000000 --- a/src/main/java/side/onetime/global/lock/aop/DistributedLockAop.java +++ /dev/null @@ -1,50 +0,0 @@ -package side.onetime.global.lock.aop; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.redisson.api.RLock; -import org.redisson.api.RedissonClient; -import org.springframework.stereotype.Component; -import side.onetime.exception.CustomException; -import side.onetime.exception.status.TokenErrorStatus; -import side.onetime.global.lock.annotation.DistributedLock; -import side.onetime.global.lock.util.CustomSpringELParser; - -@Slf4j -@Aspect -@Component -@RequiredArgsConstructor -public class DistributedLockAop { - - private final RedissonClient redissonClient; - private final CustomSpringELParser parser = new CustomSpringELParser(); - - @Around("@annotation(lock)") - public Object lock(ProceedingJoinPoint joinPoint, DistributedLock lock) throws Throwable { - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - String dynamicKey = parser.getDynamicValue(signature.getMethod(), joinPoint.getArgs(), lock.key()); - String lockName = lock.prefix() + ":" + dynamicKey; - - RLock rLock = redissonClient.getLock(lockName); - boolean available = false; - - try { - available = rLock.tryLock(lock.waitTime(), lock.leaseTime(), lock.timeUnit()); - if (!available) { - throw new CustomException(TokenErrorStatus._TOO_MANY_REQUESTS); - } - - log.debug("🔐 락 획득: {}", lockName); - return joinPoint.proceed(); - } finally { - if (available && rLock.isHeldByCurrentThread()) { - rLock.unlock(); - log.debug("🔓 락 해제: {}", lockName); - } - } - } -} diff --git a/src/main/java/side/onetime/global/lock/util/CustomSpringELParser.java b/src/main/java/side/onetime/global/lock/util/CustomSpringELParser.java deleted file mode 100644 index f1e9b58e..00000000 --- a/src/main/java/side/onetime/global/lock/util/CustomSpringELParser.java +++ /dev/null @@ -1,29 +0,0 @@ -package side.onetime.global.lock.util; - -import lombok.RequiredArgsConstructor; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.expression.EvaluationContext; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; - -import java.lang.reflect.Method; - -@RequiredArgsConstructor -public class CustomSpringELParser { - - private final SpelExpressionParser parser = new SpelExpressionParser(); - private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); - - public String getDynamicValue(Method method, Object[] args, String expression) { - EvaluationContext context = new StandardEvaluationContext(); - String[] paramNames = nameDiscoverer.getParameterNames(method); - - if (paramNames != null) { - for (int i = 0; i < paramNames.length; i++) { - context.setVariable(paramNames[i], args[i]); - } - } - - return parser.parseExpression(expression).getValue(context, String.class); - } -} From 4209ea658d95eb1ac08385d9ca44767acf5d8f47 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 12:20:54 +0900 Subject: [PATCH 22/30] =?UTF-8?q?fix:=20redisson=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 -- .../onetime/global/config/RedissonConfig.java | 34 ------------------- 2 files changed, 37 deletions(-) delete mode 100644 src/main/java/side/onetime/global/config/RedissonConfig.java diff --git a/build.gradle b/build.gradle index cb7a19d2..9d0f0821 100644 --- a/build.gradle +++ b/build.gradle @@ -47,9 +47,6 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // Redisson - implementation 'org.redisson:redisson-spring-boot-starter:3.46.0' - // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/side/onetime/global/config/RedissonConfig.java b/src/main/java/side/onetime/global/config/RedissonConfig.java deleted file mode 100644 index 4a84cfdb..00000000 --- a/src/main/java/side/onetime/global/config/RedissonConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package side.onetime.global.config; - -import org.redisson.Redisson; -import org.redisson.api.RedissonClient; -import org.redisson.config.Config; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class RedissonConfig { - - @Value("${spring.data.redis.host}") - private String redisHost; - - @Value("${spring.data.redis.port}") - private int redisPort; - - private static final String REDISSON_HOST_PREFIX = "redis://"; - - @Bean - public RedissonClient redissonClient() { - Config config = new Config(); - config.useSingleServer() - .setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort) - .setConnectionMinimumIdleSize(1) - .setConnectionPoolSize(64) - .setConnectTimeout(3000) - .setTimeout(3000) - .setRetryAttempts(3) - .setRetryInterval(1500); - return Redisson.create(config); - } -} From 6663547000d110d2048e9b384071aef444727fab Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 12:21:19 +0900 Subject: [PATCH 23/30] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20yaml=20db=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index ee954d90..6533696a 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -3,7 +3,7 @@ spring: show-sql: true generate-ddl: true hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: format_sql: true From 6171a6755d69f0a0b73550aabaaaa854ca946390 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 13:32:06 +0900 Subject: [PATCH 24/30] =?UTF-8?q?fix:=20local=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20JPA=20=EC=84=A4=EC=A0=95=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-ddl 제거 (ddl-auto와 중복) - ddl-auto: validate → update - sql.init.mode: always → never Co-Authored-By: Claude Opus 4.5 --- src/main/resources/application-local.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 6533696a..1334d125 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,15 +1,14 @@ spring: jpa: show-sql: true - generate-ddl: true hibernate: - ddl-auto: validate + ddl-auto: update properties: hibernate: format_sql: true sql: init: - mode: always + mode: never logging: level: side.onetime: debug From e1c5eb7c0c56d29ed74eb0b83eed388c01b1f0ab Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Wed, 21 Jan 2026 13:33:16 +0900 Subject: [PATCH 25/30] =?UTF-8?q?chore:=20=EC=B5=9C=EC=8B=A0=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 9d0f0821..fe0a2c72 100644 --- a/build.gradle +++ b/build.gradle @@ -95,8 +95,8 @@ dependencies { // Testcontainers testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:testcontainers' - testImplementation 'org.testcontainers:junit-jupiter:1.19.0' - testImplementation 'org.testcontainers:mysql:1.20.1' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' } // QueryDSL 디렉토리 From cb0d50e5fb34a55425f0d50092962555272fee9b Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Thu, 22 Jan 2026 01:19:10 +0900 Subject: [PATCH 26/30] =?UTF-8?q?fix:=20gitkeep=EC=9D=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index fe0a2c72..2736fc25 100644 --- a/build.gradle +++ b/build.gradle @@ -135,7 +135,6 @@ openapi3 { tasks.withType(GenerateSwaggerUI).configureEach { dependsOn 'openapi3' - delete file('src/main/resources/static/docs/') copy { from "build/resources/main/static/docs" into "src/main/resources/static/docs/" From b4d4643dc93d5210605fb7126d427b1da9729484 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Thu, 22 Jan 2026 10:16:11 +0900 Subject: [PATCH 27/30] =?UTF-8?q?fix:=20conflict=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/side/onetime/service/TestAuthService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index 04efa9e3..f41bf530 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -65,8 +65,7 @@ public TestTokenResponse login(TestLoginRequest request) { ); refreshTokenRepository.save(refreshToken); - return OnboardUserResponse.of(accessToken, refreshTokenValue); - return TestTokenResponse.of(accessToken, refreshToken); + return TestTokenResponse.of(accessToken, refreshToken.getTokenValue()); } /** From a3d22c070017245ef3a583e32609596fb4c9e815 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Thu, 22 Jan 2026 10:30:31 +0900 Subject: [PATCH 28/30] =?UTF-8?q?fix:=20=EC=8A=AC=EB=9E=98=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/side/onetime/global/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/side/onetime/global/config/SecurityConfig.java b/src/main/java/side/onetime/global/config/SecurityConfig.java index d3c920e2..21b295d3 100644 --- a/src/main/java/side/onetime/global/config/SecurityConfig.java +++ b/src/main/java/side/onetime/global/config/SecurityConfig.java @@ -74,7 +74,7 @@ public class SecurityConfig { "https://dev-app.onetime-with-members.workers.dev", "https://admin.onetime-with-members.workers.dev", "https://dev-admin.onetime-with-members.workers.dev", - "https://discord.onetime.run/", + "https://discord.onetime.run", }; /** From 54b2d8605ef2b0130c5ab7a27808701bb2c18fb5 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Sun, 25 Jan 2026 13:00:24 +0900 Subject: [PATCH 29/30] =?UTF-8?q?refactor:=20Service=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=97=90=EC=84=9C=20HttpServletRequest=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Controller에서 ClientInfoExtractor를 사용해 userIp, userAgent 추출 - Service는 추출된 값을 파라미터로 받도록 변경 - HTTP 요청과 비즈니스 로직의 결합도 감소 Co-Authored-By: Claude --- .../onetime/controller/TokenController.java | 6 ++++- .../onetime/controller/UserController.java | 7 ++++- .../side/onetime/service/TokenService.java | 10 +++---- .../side/onetime/service/UserService.java | 10 +++---- .../configuration/ControllerTestConfig.java | 10 +++++++ .../onetime/token/TokenControllerTest.java | 3 +-- .../side/onetime/token/TokenServiceTest.java | 26 ++++++------------- .../side/onetime/user/UserControllerTest.java | 3 +-- 8 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/main/java/side/onetime/controller/TokenController.java b/src/main/java/side/onetime/controller/TokenController.java index 175b585d..f0d592cd 100644 --- a/src/main/java/side/onetime/controller/TokenController.java +++ b/src/main/java/side/onetime/controller/TokenController.java @@ -14,6 +14,7 @@ import side.onetime.global.common.ApiResponse; import side.onetime.global.common.status.SuccessStatus; import side.onetime.service.TokenService; +import side.onetime.util.ClientInfoExtractor; @RestController @RequestMapping("/api/v1/tokens") @@ -21,6 +22,7 @@ public class TokenController { private final TokenService tokenService; + private final ClientInfoExtractor clientInfoExtractor; /** * 액세스 토큰 재발행 API. @@ -34,7 +36,9 @@ public ResponseEntity> reissueToken( @Valid @RequestBody ReissueTokenRequest reissueAccessTokenRequest, HttpServletRequest httpRequest) { - ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest, httpRequest); + String userIp = clientInfoExtractor.extractClientIp(httpRequest); + String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); + ReissueTokenResponse reissueTokenResponse = tokenService.reissueToken(reissueAccessTokenRequest, userIp, userAgent); return ApiResponse.onSuccess(SuccessStatus._REISSUE_TOKENS, reissueTokenResponse); } } diff --git a/src/main/java/side/onetime/controller/UserController.java b/src/main/java/side/onetime/controller/UserController.java index e0237d93..4f1c7328 100644 --- a/src/main/java/side/onetime/controller/UserController.java +++ b/src/main/java/side/onetime/controller/UserController.java @@ -29,6 +29,7 @@ import side.onetime.global.common.ApiResponse; import side.onetime.global.common.status.SuccessStatus; import side.onetime.service.UserService; +import side.onetime.util.ClientInfoExtractor; @RestController @RequestMapping("/api/v1/users") @@ -36,6 +37,7 @@ public class UserController { private final UserService userService; + private final ClientInfoExtractor clientInfoExtractor; /** * 유저 온보딩 API. @@ -43,6 +45,7 @@ public class UserController { * 제공된 레지스터 토큰을 검증한 후, 해당 정보를 기반으로 유저 데이터를 저장하고, 액세스 및 리프레쉬 토큰을 발급합니다. * * @param onboardUserRequest 유저의 레지스터 토큰, 닉네임, 약관 동의 여부, 수면 시간 정보를 포함하는 요청 객체 + * @param httpRequest HttpServletRequest (IP, User-Agent 추출용) * @return 발급된 액세스 토큰과 리프레쉬 토큰을 포함하는 응답 객체 */ @PostMapping("/onboarding") @@ -50,7 +53,9 @@ public ResponseEntity> onboardUser( @Valid @RequestBody OnboardUserRequest onboardUserRequest, HttpServletRequest httpRequest) { - OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest, httpRequest); + String userIp = clientInfoExtractor.extractClientIp(httpRequest); + String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); + OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest, userIp, userAgent); return ApiResponse.onSuccess(SuccessStatus._ONBOARD_USER, onboardUserResponse); } diff --git a/src/main/java/side/onetime/service/TokenService.java b/src/main/java/side/onetime/service/TokenService.java index d53b13be..086bd2b1 100644 --- a/src/main/java/side/onetime/service/TokenService.java +++ b/src/main/java/side/onetime/service/TokenService.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import side.onetime.domain.RefreshToken; @@ -16,7 +15,6 @@ import side.onetime.exception.CustomException; import side.onetime.exception.status.TokenErrorStatus; import side.onetime.repository.RefreshTokenRepository; -import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; @Slf4j @@ -28,7 +26,6 @@ public class TokenService { private final RefreshTokenRepository refreshTokenRepository; private final JwtUtil jwtUtil; - private final ClientInfoExtractor clientInfoExtractor; /** * 리프레시 토큰으로 액세스/리프레시 토큰을 재발행 하는 메서드. @@ -40,18 +37,17 @@ public class TokenService { * - REVOKED/EXPIRED 토큰 → 재로그인 필요 * * @param reissueTokenRequest 요청 객체 (리프레시 토큰 포함) - * @param httpRequest HttpServletRequest (IP, User-Agent 추출용) + * @param userIp 클라이언트 IP 주소 + * @param userAgent 클라이언트 User-Agent * @return 새 액세스/리프레시 토큰 * @throws CustomException 유효하지 않은 토큰이거나 요청이 너무 잦을 경우 */ @Transactional - public ReissueTokenResponse reissueToken(ReissueTokenRequest reissueTokenRequest, HttpServletRequest httpRequest) { + public ReissueTokenResponse reissueToken(ReissueTokenRequest reissueTokenRequest, String userIp, String userAgent) { String refreshToken = reissueTokenRequest.refreshToken(); jwtUtil.validateToken(refreshToken); String jti = jwtUtil.getClaimFromToken(refreshToken, "jti", String.class); - String userIp = clientInfoExtractor.extractClientIp(httpRequest); - String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); RefreshToken token = refreshTokenRepository.findByJti(jti) .orElseThrow(() -> new CustomException(TokenErrorStatus._NOT_FOUND_REFRESH_TOKEN)); diff --git a/src/main/java/side/onetime/service/UserService.java b/src/main/java/side/onetime/service/UserService.java index 8773ec21..c9842f3d 100644 --- a/src/main/java/side/onetime/service/UserService.java +++ b/src/main/java/side/onetime/service/UserService.java @@ -7,7 +7,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import side.onetime.domain.GuideViewLog; import side.onetime.domain.RefreshToken; @@ -29,7 +28,6 @@ import side.onetime.repository.GuideViewLogRepository; import side.onetime.repository.RefreshTokenRepository; import side.onetime.repository.UserRepository; -import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; import side.onetime.util.UserAuthorizationUtil; @@ -41,7 +39,6 @@ public class UserService { private final UserRepository userRepository; private final JwtUtil jwtUtil; private final GuideViewLogRepository guideViewLogRepository; - private final ClientInfoExtractor clientInfoExtractor; /** * 유저 온보딩 처리 메서드. @@ -50,11 +47,12 @@ public class UserService { * 리프레쉬 토큰은 브라우저 식별자(browserId)와 함께 MySQL에 저장됩니다. * * @param request 유저의 레지스터 토큰, 닉네임, 약관 동의, 수면 시간 등 온보딩 정보가 포함된 요청 객체 - * @param httpRequest 클라이언트 정보 추출을 위한 HttpServletRequest + * @param userIp 클라이언트 IP 주소 + * @param userAgent 클라이언트 User-Agent * @return 발급된 액세스 토큰과 리프레쉬 토큰을 포함한 응답 객체 */ @Transactional - public OnboardUserResponse onboardUser(OnboardUserRequest request, HttpServletRequest httpRequest) { + public OnboardUserResponse onboardUser(OnboardUserRequest request, String userIp, String userAgent) { String registerToken = request.registerToken(); jwtUtil.validateToken(registerToken); @@ -68,8 +66,6 @@ public OnboardUserResponse onboardUser(OnboardUserRequest request, HttpServletRe Long userId = newUser.getId(); String browserId = jwtUtil.getClaimFromToken(registerToken, "browserId", String.class); - String userIp = clientInfoExtractor.extractClientIp(httpRequest); - String userAgent = clientInfoExtractor.extractUserAgent(httpRequest); // 새 토큰 생성 String jti = UUID.randomUUID().toString(); diff --git a/src/test/java/side/onetime/configuration/ControllerTestConfig.java b/src/test/java/side/onetime/configuration/ControllerTestConfig.java index 08300ac4..67fa3b9f 100644 --- a/src/test/java/side/onetime/configuration/ControllerTestConfig.java +++ b/src/test/java/side/onetime/configuration/ControllerTestConfig.java @@ -16,8 +16,11 @@ import org.springframework.web.filter.CharacterEncodingFilter; import side.onetime.auth.service.CustomAdminDetailsService; import side.onetime.auth.service.CustomUserDetailsService; +import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -42,6 +45,9 @@ public abstract class ControllerTestConfig { @MockBean private CustomAdminDetailsService customAdminDetailsService; + @MockBean + private ClientInfoExtractor clientInfoExtractor; + @BeforeEach void setUp(final RestDocumentationContextProvider restDocumentation) { mockMvc = MockMvcBuilders.webAppContextSetup(context) @@ -51,5 +57,9 @@ void setUp(final RestDocumentationContextProvider restDocumentation) { .build(); SecurityContextHolder.clearContext(); + + // ClientInfoExtractor mock 기본 반환값 설정 + when(clientInfoExtractor.extractClientIp(any())).thenReturn("127.0.0.1"); + when(clientInfoExtractor.extractUserAgent(any())).thenReturn("Mozilla/5.0"); } } diff --git a/src/test/java/side/onetime/token/TokenControllerTest.java b/src/test/java/side/onetime/token/TokenControllerTest.java index 1b1dc739..6eaa5a51 100644 --- a/src/test/java/side/onetime/token/TokenControllerTest.java +++ b/src/test/java/side/onetime/token/TokenControllerTest.java @@ -20,7 +20,6 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; import side.onetime.configuration.ControllerTestConfig; import side.onetime.controller.TokenController; import side.onetime.dto.token.request.ReissueTokenRequest; @@ -42,7 +41,7 @@ public void reissueTokenSuccess() throws Exception { String newRefreshToken = "newRefreshToken"; ReissueTokenResponse response = ReissueTokenResponse.of(newAccessToken, newRefreshToken); - Mockito.when(tokenService.reissueToken(any(ReissueTokenRequest.class), any(HttpServletRequest.class))) + Mockito.when(tokenService.reissueToken(any(ReissueTokenRequest.class), anyString(), anyString())) .thenReturn(response); ReissueTokenRequest request = new ReissueTokenRequest(oldRefreshToken); diff --git a/src/test/java/side/onetime/token/TokenServiceTest.java b/src/test/java/side/onetime/token/TokenServiceTest.java index b66a636e..cd279411 100644 --- a/src/test/java/side/onetime/token/TokenServiceTest.java +++ b/src/test/java/side/onetime/token/TokenServiceTest.java @@ -17,7 +17,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import jakarta.servlet.http.HttpServletRequest; import side.onetime.domain.RefreshToken; import side.onetime.domain.enums.TokenStatus; import side.onetime.dto.token.request.ReissueTokenRequest; @@ -26,7 +25,6 @@ import side.onetime.exception.status.TokenErrorStatus; import side.onetime.repository.RefreshTokenRepository; import side.onetime.service.TokenService; -import side.onetime.util.ClientInfoExtractor; import side.onetime.util.JwtUtil; @ExtendWith(MockitoExtension.class) @@ -42,12 +40,6 @@ class TokenServiceTest { @Mock private JwtUtil jwtUtil; - @Mock - private ClientInfoExtractor clientInfoExtractor; - - @Mock - private HttpServletRequest httpRequest; - private static final String TEST_JTI = "test-jti-uuid"; private static final String TEST_REFRESH_TOKEN = "test.refresh.token"; private static final String TEST_NEW_ACCESS_TOKEN = "new.access.token"; @@ -61,8 +53,6 @@ class TokenServiceTest { void setUp() { // Common mock setup given(jwtUtil.getClaimFromToken(TEST_REFRESH_TOKEN, "jti", String.class)).willReturn(TEST_JTI); - given(clientInfoExtractor.extractClientIp(httpRequest)).willReturn(TEST_USER_IP); - given(clientInfoExtractor.extractUserAgent(httpRequest)).willReturn(TEST_USER_AGENT); } private RefreshToken createTestToken(TokenStatus status, LocalDateTime lastUsedAt) { @@ -115,7 +105,7 @@ void reissueToken_Success_WithActiveToken() { .willReturn(LocalDateTime.now().plusDays(14)); // when - ReissueTokenResponse response = tokenService.reissueToken(request, httpRequest); + ReissueTokenResponse response = tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT); // then assertThat(response.accessToken()).isEqualTo(TEST_NEW_ACCESS_TOKEN); @@ -142,7 +132,7 @@ void reissueToken_Fail_TokenValueMismatch() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(token)); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -161,7 +151,7 @@ void reissueToken_Fail_DuplicatedRequestWithinGracePeriod() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(rotatedToken)); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -180,7 +170,7 @@ void reissueToken_Fail_TokenReuseDetected() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(rotatedToken)); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -200,7 +190,7 @@ void reissueToken_Fail_WithRevokedToken() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(revokedToken)); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -218,7 +208,7 @@ void reissueToken_Fail_WithExpiredToken() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(expiredToken)); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -234,7 +224,7 @@ void reissueToken_Fail_TokenNotFound() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; @@ -255,7 +245,7 @@ void reissueToken_Fail_RaceCondition() { .willReturn(0); // when & then - assertThatThrownBy(() -> tokenService.reissueToken(request, httpRequest)) + assertThatThrownBy(() -> tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT)) .isInstanceOf(CustomException.class) .satisfies(ex -> { CustomException customEx = (CustomException) ex; diff --git a/src/test/java/side/onetime/user/UserControllerTest.java b/src/test/java/side/onetime/user/UserControllerTest.java index 56516a67..ed45ef79 100644 --- a/src/test/java/side/onetime/user/UserControllerTest.java +++ b/src/test/java/side/onetime/user/UserControllerTest.java @@ -20,7 +20,6 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.epages.restdocs.apispec.Schema; -import jakarta.servlet.http.HttpServletRequest; import side.onetime.configuration.UserControllerTestConfig; import side.onetime.controller.UserController; import side.onetime.domain.enums.GuideType; @@ -50,7 +49,7 @@ public class UserControllerTest extends UserControllerTestConfig { public void onboardUser() throws Exception { // given OnboardUserResponse response = new OnboardUserResponse("sampleAccessToken", "sampleRefreshToken"); - Mockito.when(userService.onboardUser(any(OnboardUserRequest.class), any(HttpServletRequest.class))).thenReturn(response); + Mockito.when(userService.onboardUser(any(OnboardUserRequest.class), anyString(), anyString())).thenReturn(response); OnboardUserRequest request = new OnboardUserRequest( "sampleRegisterToken", From e89f3b00c7d4f409886822e1cf0c47015a34df7d Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Sun, 25 Jan 2026 13:55:30 +0900 Subject: [PATCH 30/30] =?UTF-8?q?feat:=20RefreshToken=EC=97=90=20userType?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰 재발급 시 기존 userType을 유지하도록 개선 - 관리자 토큰 재발급 시 권한 강등 버그 방지 - RefreshToken 엔티티, JWT, Service 레이어 수정 Co-Authored-By: Claude --- .../onetime/auth/handler/OAuthLoginSuccessHandler.java | 4 ++-- src/main/java/side/onetime/domain/RefreshToken.java | 10 ++++++++-- .../java/side/onetime/service/TestAuthService.java | 4 ++-- src/main/java/side/onetime/service/TokenService.java | 6 +++--- src/main/java/side/onetime/service/UserService.java | 4 ++-- src/main/java/side/onetime/util/JwtUtil.java | 4 +++- .../side/onetime/token/RefreshTokenRepositoryTest.java | 7 ++++--- src/test/java/side/onetime/token/TokenServiceTest.java | 7 ++++--- 8 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java b/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java index 9c9b4ceb..f3ace6f6 100644 --- a/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java +++ b/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java @@ -155,13 +155,13 @@ private void handleExistingUser(HttpServletRequest request, HttpServletResponse // 새 토큰 생성 String jti = UUID.randomUUID().toString(); String accessToken = jwtUtil.generateAccessToken(userId, "USER"); - String refreshTokenValue = jwtUtil.generateRefreshToken(userId, browserId, jti); + String refreshTokenValue = jwtUtil.generateRefreshToken(userId, "USER", browserId, jti); LocalDateTime now = LocalDateTime.now(); LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); RefreshToken refreshToken = RefreshToken.create( - userId, jti, browserId, refreshTokenValue, + userId, "USER", jti, browserId, refreshTokenValue, now, expiryAt, userIp, userAgent ); refreshTokenRepository.save(refreshToken); diff --git a/src/main/java/side/onetime/domain/RefreshToken.java b/src/main/java/side/onetime/domain/RefreshToken.java index 2761bbee..e6697a2e 100644 --- a/src/main/java/side/onetime/domain/RefreshToken.java +++ b/src/main/java/side/onetime/domain/RefreshToken.java @@ -47,6 +47,9 @@ public class RefreshToken extends BaseEntity { @Column(name = "users_id", nullable = false) private Long userId; + @Column(name = "user_type", nullable = false, length = 20) + private String userType; + @Column(nullable = false, unique = true, length = 128) private String jti; @@ -85,6 +88,7 @@ public class RefreshToken extends BaseEntity { * 신규 Refresh Token 생성 (로그인 시) * * @param userId 사용자 ID + * @param userType 사용자 타입 (USER, ADMIN) * @param jti JWT 고유 식별자 * @param browserId 브라우저 식별자 (User-Agent 해시) * @param tokenValue Refresh Token JWT 문자열 @@ -94,13 +98,14 @@ public class RefreshToken extends BaseEntity { * @param userAgent 발급 시 User-Agent * @return 새로 생성된 RefreshToken 엔티티 */ - public static RefreshToken create(Long userId, String jti, String browserId, + public static RefreshToken create(Long userId, String userType, String jti, String browserId, String tokenValue, LocalDateTime issuedAt, LocalDateTime expiryAt, String userIp, String userAgent) { RefreshToken token = new RefreshToken(); token.familyId = UUID.randomUUID().toString(); token.userId = userId; + token.userType = userType; token.jti = jti; token.browserId = browserId; token.tokenValue = tokenValue; @@ -122,7 +127,7 @@ public static RefreshToken create(Long userId, String jti, String browserId, * @param newExpiryAt 새 만료 시각 * @param newUserIp 새 발급 시 IP * @param newUserAgent 새 발급 시 User-Agent - * @return 로테이션된 새 RefreshToken 엔티티 (같은 family_id 유지) + * @return 로테이션된 새 RefreshToken 엔티티 (같은 family_id, userType 유지) */ public RefreshToken rotate(String newJti, String newTokenValue, LocalDateTime newIssuedAt, LocalDateTime newExpiryAt, @@ -130,6 +135,7 @@ public RefreshToken rotate(String newJti, String newTokenValue, RefreshToken token = new RefreshToken(); token.familyId = this.familyId; token.userId = this.userId; + token.userType = this.userType; token.jti = newJti; token.browserId = this.browserId; token.tokenValue = newTokenValue; diff --git a/src/main/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index f41bf530..8c30d688 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -53,14 +53,14 @@ public TestTokenResponse login(TestLoginRequest request) { // 새 토큰 생성 String jti = UUID.randomUUID().toString(); String accessToken = jwtUtil.generateAccessToken(testUserId, "USER"); - String refreshTokenValue = jwtUtil.generateRefreshToken(testUserId, browserId, jti); + String refreshTokenValue = jwtUtil.generateRefreshToken(testUserId, "USER", browserId, jti); LocalDateTime now = LocalDateTime.now(); LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); // 3. Refresh Token MySQL 저장 RefreshToken refreshToken = RefreshToken.create( - testUserId, jti, browserId, refreshTokenValue, + testUserId, "USER", jti, browserId, refreshTokenValue, now, expiryAt, "127.0.0.1", "E2E-Test-Agent" ); refreshTokenRepository.save(refreshToken); diff --git a/src/main/java/side/onetime/service/TokenService.java b/src/main/java/side/onetime/service/TokenService.java index 086bd2b1..8b41289d 100644 --- a/src/main/java/side/onetime/service/TokenService.java +++ b/src/main/java/side/onetime/service/TokenService.java @@ -102,10 +102,10 @@ private ReissueTokenResponse rotateToken(RefreshToken oldToken, String userIp, S throw new CustomException(TokenErrorStatus._ALREADY_USED_REFRESH_TOKEN); } - // 새 토큰 생성 + // 새 토큰 생성 (기존 토큰의 userType 유지) String newJti = UUID.randomUUID().toString(); - String newAccessToken = jwtUtil.generateAccessToken(oldToken.getUserId(), "USER"); - String newRefreshToken = jwtUtil.generateRefreshToken(oldToken.getUserId(), oldToken.getBrowserId(), newJti); + String newAccessToken = jwtUtil.generateAccessToken(oldToken.getUserId(), oldToken.getUserType()); + String newRefreshToken = jwtUtil.generateRefreshToken(oldToken.getUserId(), oldToken.getUserType(), oldToken.getBrowserId(), newJti); LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); diff --git a/src/main/java/side/onetime/service/UserService.java b/src/main/java/side/onetime/service/UserService.java index c9842f3d..9cdcb716 100644 --- a/src/main/java/side/onetime/service/UserService.java +++ b/src/main/java/side/onetime/service/UserService.java @@ -70,13 +70,13 @@ public OnboardUserResponse onboardUser(OnboardUserRequest request, String userIp // 새 토큰 생성 String jti = UUID.randomUUID().toString(); String accessToken = jwtUtil.generateAccessToken(userId, "USER"); - String refreshTokenValue = jwtUtil.generateRefreshToken(userId, browserId, jti); + String refreshTokenValue = jwtUtil.generateRefreshToken(userId, "USER", browserId, jti); LocalDateTime now = LocalDateTime.now(); LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); RefreshToken refreshToken = RefreshToken.create( - userId, jti, browserId, refreshTokenValue, + userId, "USER", jti, browserId, refreshTokenValue, now, expiryAt, userIp, userAgent ); refreshTokenRepository.save(refreshToken); diff --git a/src/main/java/side/onetime/util/JwtUtil.java b/src/main/java/side/onetime/util/JwtUtil.java index 6498170b..bb7e6583 100644 --- a/src/main/java/side/onetime/util/JwtUtil.java +++ b/src/main/java/side/onetime/util/JwtUtil.java @@ -137,13 +137,15 @@ public String generateRegisterToken(String provider, String providerId, String n * 리프레시 토큰 생성 메서드. * * @param userId 유저 ID + * @param userType 유저 타입 (USER, ADMIN) * @param browserId 브라우저 식별값 (User-Agent 기반 해시) * @param jti JWT 고유 식별자 (Token Rotation 추적용) * @return 생성된 리프레시 토큰 */ - public String generateRefreshToken(Long userId, String browserId, String jti) { + public String generateRefreshToken(Long userId, String userType, String browserId, String jti) { return Jwts.builder() .claim("userId", userId) + .claim("userType", userType.toUpperCase()) .claim("browserId", browserId) .claim("jti", jti) .claim("type", "REFRESH_TOKEN") diff --git a/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java index 028b3882..a2ec4437 100644 --- a/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java +++ b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java @@ -33,13 +33,14 @@ class RefreshTokenRepositoryTest extends DatabaseTestConfig { private EntityManager entityManager; private static final Long TEST_USER_ID = 1L; + private static final String TEST_USER_TYPE = "USER"; private static final String TEST_BROWSER_ID = "browser-hash-123"; private static final String TEST_USER_IP = "127.0.0.1"; private static final String TEST_USER_AGENT = "Mozilla/5.0"; private RefreshToken createAndSaveToken(String jti) { RefreshToken token = RefreshToken.create( - TEST_USER_ID, jti, TEST_BROWSER_ID, "token-value-" + jti, + TEST_USER_ID, TEST_USER_TYPE, jti, TEST_BROWSER_ID, "token-value-" + jti, LocalDateTime.now(), LocalDateTime.now().plusDays(14), TEST_USER_IP, TEST_USER_AGENT ); @@ -229,7 +230,7 @@ class UpdateExpiredTokens { void updateExpiredTokens_Success() { // given RefreshToken expiredToken = RefreshToken.create( - TEST_USER_ID, "expired-jti", TEST_BROWSER_ID, "expired-token-value", + TEST_USER_ID, TEST_USER_TYPE, "expired-jti", TEST_BROWSER_ID, "expired-token-value", LocalDateTime.now().minusDays(15), LocalDateTime.now().minusDays(1), TEST_USER_IP, TEST_USER_AGENT ); @@ -237,7 +238,7 @@ void updateExpiredTokens_Success() { Long expiredId = expiredToken.getId(); RefreshToken validToken = RefreshToken.create( - TEST_USER_ID, "valid-jti", TEST_BROWSER_ID, "valid-token-value", + TEST_USER_ID, TEST_USER_TYPE, "valid-jti", TEST_BROWSER_ID, "valid-token-value", LocalDateTime.now(), LocalDateTime.now().plusDays(14), TEST_USER_IP, TEST_USER_AGENT ); diff --git a/src/test/java/side/onetime/token/TokenServiceTest.java b/src/test/java/side/onetime/token/TokenServiceTest.java index cd279411..8d401298 100644 --- a/src/test/java/side/onetime/token/TokenServiceTest.java +++ b/src/test/java/side/onetime/token/TokenServiceTest.java @@ -47,6 +47,7 @@ class TokenServiceTest { private static final String TEST_USER_IP = "127.0.0.1"; private static final String TEST_USER_AGENT = "Mozilla/5.0"; private static final String TEST_BROWSER_ID = "browser-hash-123"; + private static final String TEST_USER_TYPE = "USER"; private static final Long TEST_USER_ID = 1L; @BeforeEach @@ -57,7 +58,7 @@ void setUp() { private RefreshToken createTestToken(TokenStatus status, LocalDateTime lastUsedAt) { RefreshToken token = RefreshToken.create( - TEST_USER_ID, TEST_JTI, TEST_BROWSER_ID, TEST_REFRESH_TOKEN, + TEST_USER_ID, TEST_USER_TYPE, TEST_JTI, TEST_BROWSER_ID, TEST_REFRESH_TOKEN, LocalDateTime.now(), LocalDateTime.now().plusDays(14), TEST_USER_IP, TEST_USER_AGENT ); @@ -98,8 +99,8 @@ void reissueToken_Success_WithActiveToken() { given(refreshTokenRepository.findByJti(TEST_JTI)).willReturn(Optional.of(activeToken)); given(refreshTokenRepository.markAsRotatedIfActive(eq(1L), any(LocalDateTime.class), eq(TEST_USER_IP))) .willReturn(1); - given(jwtUtil.generateAccessToken(TEST_USER_ID, "USER")).willReturn(TEST_NEW_ACCESS_TOKEN); - given(jwtUtil.generateRefreshToken(eq(TEST_USER_ID), eq(TEST_BROWSER_ID), anyString())) + given(jwtUtil.generateAccessToken(TEST_USER_ID, TEST_USER_TYPE)).willReturn(TEST_NEW_ACCESS_TOKEN); + given(jwtUtil.generateRefreshToken(eq(TEST_USER_ID), eq(TEST_USER_TYPE), eq(TEST_BROWSER_ID), anyString())) .willReturn(TEST_NEW_REFRESH_TOKEN); given(jwtUtil.calculateRefreshTokenExpiryAt(any(LocalDateTime.class))) .willReturn(LocalDateTime.now().plusDays(14));