From 84beb82d33be8390e3a2a687a7e4785c119db9e5 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 12 Sep 2026 17:09:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8feat:=20refresh=20token=20?= =?UTF-8?q?=ED=9A=8C=EC=A0=84=20=EC=9C=A0=EC=98=88=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20=EB=9D=BD?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-prod.yml | 13 ++++- .../domain/auth/entity/UserSession.java | 7 +++ .../domain/auth/service/AuthService.java | 7 ++- .../global/config/SecurityConfig.java | 4 +- src/main/resources/application.yml | 1 + .../domain/auth/service/AuthServiceTest.java | 51 ++++++++++++++++++- 6 files changed, 76 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index cc43dc8..bb51f88 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -87,6 +87,7 @@ jobs: env: LOCK_OWNER: ${{ github.repository }}:${{ github.run_id }}:${{ github.run_attempt }} run: | + LOCK_ERROR_FILE="$RUNNER_TEMP/deploy-lock-error.log" for i in $(seq 1 60); do NOW="$(date +%s)" EXPIRES_AT="$((NOW + 900))" @@ -101,11 +102,19 @@ jobs: --item "$ITEM" \ --condition-expression 'attribute_not_exists(#lock) OR #expires < :now' \ --expression-attribute-names '{"#lock":"lock_name","#expires":"expires_at"}' \ - --expression-attribute-values "$VALUES"; then + --expression-attribute-values "$VALUES" \ + 2>"$LOCK_ERROR_FILE"; then echo "owner=$LOCK_OWNER" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Another ECS deployment is running... ($i/60)" + + if ! grep -q 'ConditionalCheckFailedException' "$LOCK_ERROR_FILE"; then + echo "Failed to acquire ECS deploy lock:" + sed -n '1,20p' "$LOCK_ERROR_FILE" + exit 1 + fi + + echo "Another ECS deployment owns the lock... ($i/60)" sleep 5 done echo "Timed out waiting for ECS deploy lock" diff --git a/src/main/java/com/hanspoon/backend_api/domain/auth/entity/UserSession.java b/src/main/java/com/hanspoon/backend_api/domain/auth/entity/UserSession.java index 54483b2..1b2caf4 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/auth/entity/UserSession.java +++ b/src/main/java/com/hanspoon/backend_api/domain/auth/entity/UserSession.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; +import java.time.Duration; import java.time.Instant; import java.util.UUID; import lombok.AccessLevel; @@ -51,6 +52,12 @@ public boolean isActive(Instant now) { return revokedAt == null && expiresAt.isAfter(now); } + public boolean isWithinRotationGrace(Instant now, Duration grace) { + return revokedAt != null + && expiresAt.isAfter(now) + && revokedAt.plus(grace).isAfter(now); + } + public void revoke(Instant now) { if (revokedAt == null) { this.revokedAt = now; diff --git a/src/main/java/com/hanspoon/backend_api/domain/auth/service/AuthService.java b/src/main/java/com/hanspoon/backend_api/domain/auth/service/AuthService.java index df4015f..0d3a010 100644 --- a/src/main/java/com/hanspoon/backend_api/domain/auth/service/AuthService.java +++ b/src/main/java/com/hanspoon/backend_api/domain/auth/service/AuthService.java @@ -32,6 +32,7 @@ public class AuthService { private final UserSessionRepository userSessionRepository; private final UserProfileRepository userProfileRepository; private final Duration refreshTokenExpiration; + private final Duration refreshRotationGrace; public AuthService( GoogleIdTokenVerifier googleIdTokenVerifier, @@ -41,7 +42,8 @@ public AuthService( UserAuthIdentityRepository userAuthIdentityRepository, UserSessionRepository userSessionRepository, UserProfileRepository userProfileRepository, - @Value("${app.security.jwt.refresh-token-expiration}") Duration refreshTokenExpiration) { + @Value("${app.security.jwt.refresh-token-expiration}") Duration refreshTokenExpiration, + @Value("${app.security.jwt.refresh-rotation-grace}") Duration refreshRotationGrace) { this.googleIdTokenVerifier = googleIdTokenVerifier; this.jwtTokenProvider = jwtTokenProvider; @@ -51,6 +53,7 @@ public AuthService( this.userSessionRepository = userSessionRepository; this.userProfileRepository = userProfileRepository; this.refreshTokenExpiration = refreshTokenExpiration; + this.refreshRotationGrace = refreshRotationGrace; } @Transactional @@ -104,7 +107,7 @@ public TokenResult refresh(String rawRefreshToken) { .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN, "Invalid refresh token.")); Instant now = Instant.now(); - if (!session.isActive(now)) { + if (!session.isActive(now) && !session.isWithinRotationGrace(now, refreshRotationGrace)) { throw new BusinessException(ErrorCode.INVALID_TOKEN, "Expired or revoked refresh token."); } diff --git a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java index 81cccd6..01359c5 100644 --- a/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java +++ b/src/main/java/com/hanspoon/backend_api/global/config/SecurityConfig.java @@ -68,8 +68,8 @@ public SecurityFilterChain securityFilterChain( .permitAll() .anyRequest() .authenticated()) - .oauth2ResourceServer( - oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) + .oauth2ResourceServer(oauth2 -> oauth2.authenticationEntryPoint(authenticationEntryPoint) + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) .build(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef2c1cd..b1c3797 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -64,3 +64,4 @@ app: secret: ${JWT_SECRET:han-spoon-local-development-secret-key-must-be-at-least-32-bytes} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:30m} refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:14d} + refresh-rotation-grace: ${JWT_REFRESH_ROTATION_GRACE:30s} diff --git a/src/test/java/com/hanspoon/backend_api/domain/auth/service/AuthServiceTest.java b/src/test/java/com/hanspoon/backend_api/domain/auth/service/AuthServiceTest.java index ca7dd65..725dad2 100644 --- a/src/test/java/com/hanspoon/backend_api/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/hanspoon/backend_api/domain/auth/service/AuthServiceTest.java @@ -67,7 +67,8 @@ void setUp() { userAuthIdentityRepository, userSessionRepository, userProfileRepository, - Duration.ofDays(14)); + Duration.ofDays(14), + Duration.ofSeconds(30)); } private Jwt googleJwt(String sub, String email, String name) { @@ -150,6 +151,54 @@ void refresh_expiredSession_throwsInvalidToken() { .isEqualTo(ErrorCode.INVALID_TOKEN); } + @Test + void refresh_revokedWithinGrace_stillRotates() { + // 응답 유실·다중 탭으로 이미 폐기된 토큰이 재사용되는 경우 + UUID userId = UUID.randomUUID(); + Instant now = Instant.now(); + String rawToken = "rotated-5s-ago"; + String hash = refreshTokenSupport.sha256Hex(rawToken); + UserSession session = UserSession.issue(userId, hash, now.plus(Duration.ofDays(7)), now); + session.revoke(now.minus(Duration.ofSeconds(5))); + + when(userSessionRepository.findByRefreshTokenHash(hash)).thenReturn(Optional.of(session)); + when(jwtTokenProvider.createAccessToken(userId)).thenReturn("new-access-token"); + + TokenResult result = authService.refresh(rawToken); + + assertThat(result.accessToken()).isEqualTo("new-access-token"); + assertThat(result.refreshToken()).isNotBlank().isNotEqualTo(rawToken); + } + + @Test + void refresh_revokedBeyondGrace_throwsInvalidToken() { + UUID userId = UUID.randomUUID(); + Instant now = Instant.now(); + String rawToken = "rotated-long-ago"; + String hash = refreshTokenSupport.sha256Hex(rawToken); + UserSession session = UserSession.issue(userId, hash, now.plus(Duration.ofDays(7)), now); + session.revoke(now.minus(Duration.ofMinutes(5))); + + when(userSessionRepository.findByRefreshTokenHash(hash)).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> authService.refresh(rawToken)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_TOKEN); + } + + @Test + void refresh_reusedWithinGrace_doesNotExtendGraceWindow() { + UUID userId = UUID.randomUUID(); + Instant now = Instant.now(); + Instant revokedAt = now.minus(Duration.ofSeconds(5)); + UserSession session = UserSession.issue(userId, "hash", now.plus(Duration.ofDays(7)), now); + session.revoke(revokedAt); + session.revoke(now); + + assertThat(session.getRevokedAt()).isEqualTo(revokedAt); + } + @Test void refresh_unknownToken_throwsInvalidToken() { when(userSessionRepository.findByRefreshTokenHash(anyString())).thenReturn(Optional.empty()); From 93d4b1e8f0cd51f2db07157ee04f3dfd06264057 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 12 Sep 2026 17:12:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8feat:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20ProblemDetail=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../SecurityErrorResponseIntegrationTest.java | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/test/java/com/hanspoon/backend_api/global/config/SecurityErrorResponseIntegrationTest.java diff --git a/build.gradle.kts b/build.gradle.kts index ac5a352..dc27159 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.springframework.boot:spring-boot-webmvc-test") testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/test/java/com/hanspoon/backend_api/global/config/SecurityErrorResponseIntegrationTest.java b/src/test/java/com/hanspoon/backend_api/global/config/SecurityErrorResponseIntegrationTest.java new file mode 100644 index 0000000..475c46a --- /dev/null +++ b/src/test/java/com/hanspoon/backend_api/global/config/SecurityErrorResponseIntegrationTest.java @@ -0,0 +1,50 @@ +package com.hanspoon.backend_api.global.config; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.hanspoon.backend_api.TestcontainersConfiguration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * 인증 실패 응답 계약. 토큰 부재와 토큰 무효는 서로 다른 entry point 를 타므로 둘 다 확인한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class SecurityErrorResponseIntegrationTest { + + private static final String PROTECTED_ENDPOINT = "/api/v1/users/me"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("Authorization 헤더 없음 → ProblemDetail 본문") + void missingTokenReturnsProblemDetail() throws Exception { + mockMvc.perform(get(PROTECTED_ENDPOINT)) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.code").value("INVALID_TOKEN")); + } + + @Test + @DisplayName("토큰이 있으나 무효 → 빈 본문이 아니라 ProblemDetail 본문") + void invalidTokenReturnsProblemDetailNotEmptyBody() throws Exception { + // 본문이 비면 클라이언트가 만료·권한 부족을 구분할 수 없다 + mockMvc.perform(get(PROTECTED_ENDPOINT).header(HttpHeaders.AUTHORIZATION, "Bearer not-a-jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.code").value("INVALID_TOKEN")); + } +}