diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ef290170..87149cc7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @bbbang105 +* @bbbang105 @anxi01 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' 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) { 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: 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 diff --git a/build.gradle b/build.gradle index 4f9d925a..2736fc25 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' @@ -94,6 +91,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' + testImplementation 'org.testcontainers:mysql' } // QueryDSL ๋””๋ ‰ํ† ๋ฆฌ @@ -132,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/" diff --git a/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java b/src/main/java/side/onetime/auth/handler/OAuthLoginSuccessHandler.java index d1c384dd..f3ace6f6 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, "USER", 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, "USER", 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/TokenController.java b/src/main/java/side/onetime/controller/TokenController.java index 0ac7b2e2..f0d592cd 100644 --- a/src/main/java/side/onetime/controller/TokenController.java +++ b/src/main/java/side/onetime/controller/TokenController.java @@ -1,17 +1,20 @@ 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; import side.onetime.global.common.status.SuccessStatus; import side.onetime.service.TokenService; +import side.onetime.util.ClientInfoExtractor; @RestController @RequestMapping("/api/v1/tokens") @@ -19,18 +22,23 @@ public class TokenController { private final TokenService tokenService; + private final ClientInfoExtractor clientInfoExtractor; /** * ์•ก์„ธ์Šค ํ† ํฐ ์žฌ๋ฐœํ–‰ 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); + 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 b4bbbd50..4f1c7328 100644 --- a/src/main/java/side/onetime/controller/UserController.java +++ b/src/main/java/side/onetime/controller/UserController.java @@ -1,15 +1,35 @@ 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; +import side.onetime.util.ClientInfoExtractor; @RestController @RequestMapping("/api/v1/users") @@ -17,6 +37,7 @@ public class UserController { private final UserService userService; + private final ClientInfoExtractor clientInfoExtractor; /** * ์œ ์ € ์˜จ๋ณด๋”ฉ API. @@ -24,13 +45,17 @@ public class UserController { * ์ œ๊ณต๋œ ๋ ˆ์ง€์Šคํ„ฐ ํ† ํฐ์„ ๊ฒ€์ฆํ•œ ํ›„, ํ•ด๋‹น ์ •๋ณด๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์œ ์ € ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•˜๊ณ , ์•ก์„ธ์Šค ๋ฐ ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. * * @param onboardUserRequest ์œ ์ €์˜ ๋ ˆ์ง€์Šคํ„ฐ ํ† ํฐ, ๋‹‰๋„ค์ž„, ์•ฝ๊ด€ ๋™์˜ ์—ฌ๋ถ€, ์ˆ˜๋ฉด ์‹œ๊ฐ„ ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋Š” ์š”์ฒญ ๊ฐ์ฒด + * @param httpRequest HttpServletRequest (IP, User-Agent ์ถ”์ถœ์šฉ) * @return ๋ฐœ๊ธ‰๋œ ์•ก์„ธ์Šค ํ† ํฐ๊ณผ ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ ํฌํ•จํ•˜๋Š” ์‘๋‹ต ๊ฐ์ฒด */ @PostMapping("/onboarding") public ResponseEntity> onboardUser( - @Valid @RequestBody OnboardUserRequest onboardUserRequest) { + @Valid @RequestBody OnboardUserRequest onboardUserRequest, + HttpServletRequest httpRequest) { - OnboardUserResponse onboardUserResponse = userService.onboardUser(onboardUserRequest); + 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/domain/RefreshToken.java b/src/main/java/side/onetime/domain/RefreshToken.java index 5a07f1cb..e6697a2e 100644 --- a/src/main/java/side/onetime/domain/RefreshToken.java +++ b/src/main/java/side/onetime/domain/RefreshToken.java @@ -1,18 +1,170 @@ 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(name = "user_type", nullable = false, length = 20) + private String userType; + + @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 userType ์‚ฌ์šฉ์ž ํƒ€์ž… (USER, ADMIN) + * @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 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; + 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, userType ์œ ์ง€) + */ + 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.userType = this.userType; + 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/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); - } -} 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", }; /** diff --git a/src/main/java/side/onetime/global/filter/JwtFilter.java b/src/main/java/side/onetime/global/filter/JwtFilter.java index 30f80bdb..607bb714 100644 --- a/src/main/java/side/onetime/global/filter/JwtFilter.java +++ b/src/main/java/side/onetime/global/filter/JwtFilter.java @@ -100,7 +100,9 @@ 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") || + path.equals("/api/v1/users/logout"); } /** 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); - } -} 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..eae522b4 --- /dev/null +++ b/src/main/java/side/onetime/repository/custom/RefreshTokenRepositoryImpl.java @@ -0,0 +1,72 @@ +package side.onetime.repository.custom; + +import static side.onetime.domain.QRefreshToken.*; + +import java.time.LocalDateTime; + +import org.springframework.transaction.annotation.Transactional; + +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 + @Transactional + 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))) + .execute(); + } + + @Override + @Transactional + 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(); + } + + @Override + @Transactional + 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(); + } + + @Override + @Transactional + 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(); + } + + @Override + @Transactional + 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/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) 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/java/side/onetime/service/TestAuthService.java b/src/main/java/side/onetime/service/TestAuthService.java index f84c9044..8c30d688 100644 --- a/src/main/java/side/onetime/service/TestAuthService.java +++ b/src/main/java/side/onetime/service/TestAuthService.java @@ -1,8 +1,12 @@ 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; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import side.onetime.domain.RefreshToken; @@ -35,20 +39,33 @@ public class TestAuthService { * @param request ํ…Œ์ŠคํŠธ ๋กœ๊ทธ์ธ ์š”์ฒญ (์‹œํฌ๋ฆฟ ํ‚ค ํฌํ•จ) * @return Access Token๊ณผ Refresh Token์„ ํฌํ•จํ•˜๋Š” ์‘๋‹ต ๊ฐ์ฒด */ + @Transactional public TestTokenResponse login(TestLoginRequest request) { // 1. ์‹œํฌ๋ฆฟ ํ‚ค ๊ฒ€์ฆ validateSecretKey(request.secretKey()); // 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, "USER", browserId, jti); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); + + // 3. Refresh Token MySQL ์ €์žฅ + RefreshToken refreshToken = RefreshToken.create( + testUserId, "USER", jti, browserId, refreshTokenValue, + now, expiryAt, "127.0.0.1", "E2E-Test-Agent" + ); + refreshTokenRepository.save(refreshToken); - return TestTokenResponse.of(accessToken, refreshToken); + return TestTokenResponse.of(accessToken, refreshToken.getTokenValue()); } /** diff --git a/src/main/java/side/onetime/service/TokenService.java b/src/main/java/side/onetime/service/TokenService.java index cd5ae55d..8b41289d 100644 --- a/src/main/java/side/onetime/service/TokenService.java +++ b/src/main/java/side/onetime/service/TokenService.java @@ -1,14 +1,19 @@ package side.onetime.service; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + 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.JwtUtil; @@ -17,52 +22,107 @@ @RequiredArgsConstructor public class TokenService { + private static final int GRACE_PERIOD_SECONDS = 3; + private final RefreshTokenRepository refreshTokenRepository; private final JwtUtil jwtUtil; /** * ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์œผ๋กœ ์•ก์„ธ์Šค/๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์„ ์žฌ๋ฐœํ–‰ ํ•˜๋Š” ๋ฉ”์„œ๋“œ. * - * - ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์—์„œ 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 userIp ํด๋ผ์ด์–ธํŠธ IP ์ฃผ์†Œ + * @param userAgent ํด๋ผ์ด์–ธํŠธ 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, String userIp, String userAgent) { String refreshToken = reissueTokenRequest.refreshToken(); - Long userId = jwtUtil.getClaimFromToken(refreshToken, "userId", Long.class); - String browserId = jwtUtil.getClaimFromToken(refreshToken, "browserId", String.class); + jwtUtil.validateToken(refreshToken); + String jti = jwtUtil.getClaimFromToken(refreshToken, "jti", String.class); - // ์ฟจ๋‹ค์šด ์ฒดํฌ - 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); + } + + // ์ƒˆ ํ† ํฐ ์ƒ์„ฑ (๊ธฐ์กด ํ† ํฐ์˜ userType ์œ ์ง€) + String newJti = UUID.randomUUID().toString(); + String newAccessToken = jwtUtil.generateAccessToken(oldToken.getUserId(), oldToken.getUserType()); + String newRefreshToken = jwtUtil.generateRefreshToken(oldToken.getUserId(), oldToken.getUserType(), 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()); + } } diff --git a/src/main/java/side/onetime/service/UserService.java b/src/main/java/side/onetime/service/UserService.java index cc82acee..9cdcb716 100644 --- a/src/main/java/side/onetime/service/UserService.java +++ b/src/main/java/side/onetime/service/UserService.java @@ -1,14 +1,28 @@ 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 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; @@ -17,8 +31,6 @@ import side.onetime.util.JwtUtil; import side.onetime.util.UserAuthorizationUtil; -import java.util.Optional; - @Service @RequiredArgsConstructor public class UserService { @@ -32,13 +44,15 @@ public class UserService { * ์œ ์ € ์˜จ๋ณด๋”ฉ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ. * * ํšŒ์›๊ฐ€์ž… ์ดํ›„ ํ•„์ˆ˜ ์ •๋ณด๋ฅผ ์„ค์ •ํ•˜๊ณ  ์œ ์ €๋ฅผ ์ €์žฅํ•œ ๋’ค, ์•ก์„ธ์Šค ํ† ํฐ๊ณผ ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. - * ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์€ ๋ธŒ๋ผ์šฐ์ € ์‹๋ณ„์ž(browserId)์™€ ํ•จ๊ป˜ Redis์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. + * ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์€ ๋ธŒ๋ผ์šฐ์ € ์‹๋ณ„์ž(browserId)์™€ ํ•จ๊ป˜ MySQL์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. * * @param request ์œ ์ €์˜ ๋ ˆ์ง€์Šคํ„ฐ ํ† ํฐ, ๋‹‰๋„ค์ž„, ์•ฝ๊ด€ ๋™์˜, ์ˆ˜๋ฉด ์‹œ๊ฐ„ ๋“ฑ ์˜จ๋ณด๋”ฉ ์ •๋ณด๊ฐ€ ํฌํ•จ๋œ ์š”์ฒญ ๊ฐ์ฒด + * @param userIp ํด๋ผ์ด์–ธํŠธ IP ์ฃผ์†Œ + * @param userAgent ํด๋ผ์ด์–ธํŠธ User-Agent * @return ๋ฐœ๊ธ‰๋œ ์•ก์„ธ์Šค ํ† ํฐ๊ณผ ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ ํฌํ•จํ•œ ์‘๋‹ต ๊ฐ์ฒด */ @Transactional - public OnboardUserResponse onboardUser(OnboardUserRequest request) { + public OnboardUserResponse onboardUser(OnboardUserRequest request, String userIp, String userAgent) { String registerToken = request.registerToken(); jwtUtil.validateToken(registerToken); @@ -52,11 +66,22 @@ public OnboardUserResponse onboardUser(OnboardUserRequest request) { Long userId = newUser.getId(); String browserId = jwtUtil.getClaimFromToken(registerToken, "browserId", String.class); + + // ์ƒˆ ํ† ํฐ ์ƒ์„ฑ + 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, "USER", browserId, jti); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime expiryAt = jwtUtil.calculateRefreshTokenExpiryAt(now); - return OnboardUserResponse.of(accessToken, refreshToken); + RefreshToken refreshToken = RefreshToken.create( + userId, "USER", jti, browserId, refreshTokenValue, + now, expiryAt, userIp, userAgent + ); + refreshTokenRepository.save(refreshToken); + + return OnboardUserResponse.of(accessToken, refreshTokenValue); } /** @@ -120,14 +145,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 +220,7 @@ public void updateUserSleepTime(UpdateUserSleepTimeRequest request) { /** * ์œ ์ € ๋กœ๊ทธ์•„์›ƒ ๋ฉ”์„œ๋“œ. * - * ๋กœ๊ทธ์•„์›ƒ ์‹œ, ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. + * ๋กœ๊ทธ์•„์›ƒ ์‹œ, ํ•ด๋‹น ๋ธŒ๋ผ์šฐ์ €์˜ ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ์„ REVOKED ์ƒํƒœ๋กœ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. * * @param request ๋ฆฌํ”„๋ ˆ์‰ฌ ํ† ํฐ ์š”์ฒญ ๋ฐ์ดํ„ฐ */ @@ -206,7 +230,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; + } +} diff --git a/src/main/java/side/onetime/util/JwtUtil.java b/src/main/java/side/onetime/util/JwtUtil.java index 21dda23e..bb7e6583 100644 --- a/src/main/java/side/onetime/util/JwtUtil.java +++ b/src/main/java/side/onetime/util/JwtUtil.java @@ -3,6 +3,7 @@ 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; @@ -136,13 +137,17 @@ 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) { + 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") .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION_TIME)) @@ -150,6 +155,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 ํ† ํฐ ์ถ”์ถœ. * diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index ee954d90..1334d125 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -1,7 +1,6 @@ spring: jpa: show-sql: true - generate-ddl: true hibernate: ddl-auto: update properties: @@ -9,7 +8,7 @@ spring: format_sql: true sql: init: - mode: always + mode: never logging: level: side.onetime: debug 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 diff --git a/src/main/resources/static/docs/.gitkeep b/src/main/resources/static/docs/.gitkeep new file mode 100644 index 00000000..e69de29b 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 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/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/java/side/onetime/token/RefreshTokenRepositoryTest.java b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java new file mode 100644 index 00000000..a2ec4437 --- /dev/null +++ b/src/test/java/side/onetime/token/RefreshTokenRepositoryTest.java @@ -0,0 +1,288 @@ +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_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, TEST_USER_TYPE, 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, 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 + ); + expiredToken = refreshTokenRepository.saveAndFlush(expiredToken); + Long expiredId = expiredToken.getId(); + + RefreshToken validToken = RefreshToken.create( + 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 + ); + 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..6eaa5a51 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,17 @@ 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 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 +41,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), 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 new file mode 100644 index 00000000..8d401298 --- /dev/null +++ b/src/test/java/side/onetime/token/TokenServiceTest.java @@ -0,0 +1,260 @@ +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 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.JwtUtil; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TokenService ํ…Œ์ŠคํŠธ") +class TokenServiceTest { + + @InjectMocks + private TokenService tokenService; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private JwtUtil jwtUtil; + + 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 String TEST_USER_TYPE = "USER"; + 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); + } + + private RefreshToken createTestToken(TokenStatus status, LocalDateTime lastUsedAt) { + RefreshToken token = RefreshToken.create( + 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 + ); + + // 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, 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)); + + // when + ReissueTokenResponse response = tokenService.reissueToken(request, TEST_USER_IP, TEST_USER_AGENT); + + // 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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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, TEST_USER_IP, TEST_USER_AGENT)) + .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..ed45ef79 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,29 @@ 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 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 +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))).thenReturn(response); + Mockito.when(userService.onboardUser(any(OnboardUserRequest.class), anyString(), anyString())).thenReturn(response); OnboardUserRequest request = new OnboardUserRequest( "sampleRegisterToken", 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