Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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))"
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -51,6 +53,7 @@ public AuthService(
this.userSessionRepository = userSessionRepository;
this.userProfileRepository = userProfileRepository;
this.refreshTokenExpiration = refreshTokenExpiration;
this.refreshRotationGrace = refreshRotationGrace;
}

@Transactional
Expand Down Expand Up @@ -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.");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Loading