Skip to content

API 서비스 - 애플리케이션 및 인증 구축#25

Merged
VitoJeong merged 16 commits intoon-seoul-apifrom
API-5-auth
Apr 28, 2026
Merged

API 서비스 - 애플리케이션 및 인증 구축#25
VitoJeong merged 16 commits intoon-seoul-apifrom
API-5-auth

Conversation

@VitoJeong
Copy link
Copy Markdown
Collaborator

개요

Epic 4 애플리케이션 구동 설정과 Epic 5 인증을 구현했습니다.

구현 항목

작업내용 문서는 on-seoul-api/docs/api-service-implementation.md를 참고해주세요

애플리케이션 구동

  • Gradle Wrapper 및 멀티모듈 빌드 스크립트 구성
  • Spring Web, Security, Data JPA, Redis, PostgreSQL 의존성 추가
  • 프로파일별 application.yml DB / Redis 접속 정보 구성

인증

  • app 스키마 users, query_history 테이블 DDL 및 엔티티 정의
  • Spring Security OAuth2 Client 설정 (Google 로그인)
  • OAuth2 로그인 핸들러 및 JWT 검증 필터 구현
  • Refresh Token Redis 저장

changha and others added 7 commits April 20, 2026 23:34
- User: OAuth2 소셜 계정 정보 (provider, provider_id, email, nickname, status)
- ChatRoom: 사용자 대화 세션 단위
- ChatMessage: 메시지 단위 저장 (USER/ASSISTANT role)
- UserStatus / ChatMessageRole enum으로 타입 안전성 확보
- ErrorCode에 UNAUTHORIZED / FORBIDDEN / INVALID_TOKEN / EXPIRED_TOKEN / INVALID_REFRESH_TOKEN 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JwtProvider: HS256 Access Token(15분) / Refresh Token(7일) 생성·검증
- JwtAuthenticationFilter: Bearer 토큰 파싱 + SecurityContext 설정
- OAuth2LoginSuccessHandler: 소셜 로그인 후 users upsert + JWT 발급 + Redis(RT:{userId}) 저장
- AuthService: Token Rotation — 갱신 시 구 토큰 즉시 폐기 후 신 토큰 발급
- POST /auth/token/refresh, POST /auth/logout 엔드포인트 추가
- SecurityConfig: /admin/** 인증 필수, STATELESS 세션, 401/403 JSON 응답
- GlobalExceptionHandler: OnSeoulApiException → JSON 응답
- SUSPENDED/DELETED 계정 로그인·갱신 차단

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SecurityConfig: SessionCreationPolicy STATELESS → IF_REQUIRED
  OAuth2 Code Flow의 state 검증을 위해 세션 필요
- SecurityConfig: /oauth2/authorization/**, /login/oauth2/code/** permitAll 추가
- OAuth2LoginSuccessHandler: providerId 추출 시 char[] ClassCastException 수정
  (String.valueOf → Object.toString() 방어 처리)
- OAuth2LoginSuccessHandler: Kakao 중첩 속성(kakao_account/properties) 파싱 추가
- application.yml: Google user-name-attribute id → sub (OIDC openid 스코프 호환)
- application.yml: Google/Kakao registration 및 provider 블록 추가
- application-test.yml: Kakao provider stub 추가
- OAuth2LoginSuccessHandlerIntegrationTest: Spring 컨텍스트 없이 핸들러 직접 검증
  (Google 신규/재로그인, SUSPENDED 차단, Kakao 속성 파싱, email null 허용)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@VitoJeong VitoJeong requested a review from f-lab-ted April 27, 2026 13:31
Copy link
Copy Markdown

@f-lab-ted f-lab-ted left a comment

Choose a reason for hiding this comment

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

  • Gradle 멀티모듈 빌드 위에 Spring Security OAuth2 Client(Google/Kakao) + JWT(HS256) 기반 Stateless 인증 체계를 구축하였으며, Refresh Token을 Redis에 저장하고 Token Rotation 방식으로 재발급하는 구조
  • 중점적으로 JWT 생성·검증 로직의 보안성, OAuth2 콜백 처리 흐름, Token Rotation의 동시성 안전성, SecurityConfig의 접근 제어 정합성을 검토

잘한 점

Token Rotation을 통해 Refresh Token 탈취 시 1회 사용 후 즉시 무효화되는 설계가 잘 적용되어 있고, extractUserIdSafely로 필터에서 파싱을 1회로 줄인 점, 비활성 계정 차단을 OAuth2 로그인과 토큰 갱신 양쪽에서 모두 처리한 점이 좋습니다.

보완할 점

  1. Access Token과 Refresh Token이 구조적으로 구분되지 않아, Refresh Token을 Access Token으로 사용하는 토큰 혼용 공격이 가능합니다. type 클레임 추가가 필요합니다.
  2. OAuth2 성공 핸들러가 JSON을 직접 응답하는데, 브라우저 기반 OAuth2 Code Flow에서는 프론트엔드로 리다이렉트해야 SPA가 토큰을 수신할 수 있습니다.
  3. Token Rotation의 Redis get → delete 사이에 경합 조건이 존재하여, 동시 요청 시 같은 Refresh Token이 두 번 사용될 수 있습니다.
  4. /auth/** 경로가 전체 permitAll로 설정되어 /auth/logout이 인증 없이 호출 가능합니다.

결론

Request Changes

  • JWT 토큰 타입 구분 미비(보안), OAuth2 콜백 응답 방식(기능), Token Rotation 경합 조건(동시성) 세 가지 반드시 수정 바람.
  • 나머지 코멘트는 검토 후 반영 여부 결정.

return buildToken(userId, refreshTokenMinutes);
}

private String buildToken(long userId, long ttlMinutes) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Critical - 보안] Access Token과 Refresh Token이 구조적으로 동일하여 토큰 혼용 공격에 취약합니다.

현재 buildTokensubject와 만료 시간만 설정하므로, Refresh Token을 탈취한 공격자가 이를 Access Token으로 사용해 API를 호출할 수 있습니다. JwtAuthenticationFilter.extractUserIdSafely는 토큰 타입을 검사하지 않기 때문입니다.

type 클레임을 추가하고, 필터와 서비스에서 각각 검증하세요:

private String buildToken(long userId, long ttlMinutes, String tokenType) {
    Date now = new Date();
    Date expiry = new Date(now.getTime() + ttlMinutes * 60_000L);
    return Jwts.builder()
            .subject(String.valueOf(userId))
            .claim("type", tokenType)  // "access" or "refresh"
            .issuedAt(now)
            .expiration(expiry)
            .signWith(signingKey)
            .compact();
}

그리고 extractUserIdSafely에서 type"access"인지, AuthService.refresh에서 type"refresh"인지 각각 확인해야 합니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

넵 type 클레임으로 분리하고 검증하도록 변경했습니다!


TokenResponse tokenResponse = new TokenResponse(accessToken, refreshToken);

response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major - 기능] OAuth2 Authorization Code Flow의 Best Practice에 따라 토큰 응답 방식을 개선해야 합니다.

OAuth 2.0 Authorization Code Flow와 현재 구현의 차이

OAuth 2.0 Authorization Code Flow(RFC 6749 Section 4.1)에서 브라우저 기반 클라이언트의 토큰 수신 과정은 다음과 같습니다:

  1. 사용자 → Authorization Server로 리다이렉트 (Google/Kakao 로그인 페이지)
  2. Authorization Server → 인가 코드와 함께 Redirect URI로 리다이렉트
  3. 백엔드(Resource Server) → 인가 코드로 토큰 교환 후, 프론트엔드에 토큰 전달

핵심은 3번 단계에서 브라우저가 콜백 URL을 직접 방문한다는 점입니다. 이 시점에서 JSON을 HTTP Response Body에 쓰면:

  • 브라우저가 raw JSON을 그대로 렌더링합니다
  • SPA(JavaScript)가 이 응답을 프로그래밍적으로 수신할 방법이 없습니다
  • OAuth 플로우에서 이 콜백은 XHR/fetch 요청이 아닌 full page navigation이기 때문입니다

Best Practice 관점

OAuth 2.0 BCP(RFC 9700, 구 OAuth 2.0 Security Best Current Practice)에서 권장하는 방식:

방식 보안성 설명
HttpOnly 쿠키 + 리다이렉트 (권장) 높음 XSS로부터 토큰 보호, CSRF 방어 필요
Fragment(#) 리다이렉트 중간 서버 로그에 노출되지 않으나 브라우저 히스토리에 남을 수 있음
Query Parameter 리다이렉트 낮음 서버 로그/Referer 헤더 노출 위험, HTTPS 필수

수정 제안

// 방법 1 (권장): HttpOnly 쿠키 설정 후 프론트엔드로 리다이렉트
// - Refresh Token은 HttpOnly; Secure; SameSite=Strict 쿠키로 설정
// - Access Token은 짧은 수명의 쿠키 또는 프론트엔드에서 쿠키 기반으로 재발급

ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken)
    .httpOnly(true)
    .secure(true)
    .sameSite("Strict")
    .path("/api/auth/refresh")
    .maxAge(Duration.ofDays(7))
    .build();
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());

ResponseCookie accessCookie = ResponseCookie.from("access_token", accessToken)
    .httpOnly(true)
    .secure(true)
    .sameSite("Strict")
    .path("/")
    .maxAge(Duration.ofMinutes(30))
    .build();
response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString());

response.sendRedirect(frontendBaseUrl + "/oauth/callback?status=success");
// 방법 2: Fragment 기반 리다이렉트 (쿠키 방식이 어려운 경우)
// - Fragment(#)는 서버로 전송되지 않아 Query Param보다 안전
String redirectUrl = frontendBaseUrl + "/oauth/callback"
    + "#access_token=" + accessToken
    + "&token_type=bearer";
response.sendRedirect(redirectUrl);

현재 방식은 Postman이나 E2E 테스트에서는 정상 동작하지만, 실제 브라우저 기반 OAuth 플로우에서는 SPA가 토큰을 수신할 수 없습니다. README에도 "프론트엔드 리다이렉트"로 설명되어 있으므로, 위 방식 중 하나를 적용하여 실제 브라우저 환경에서도 정상 동작하도록 수정이 필요합니다.

Copy link
Copy Markdown
Collaborator Author

@VitoJeong VitoJeong Apr 28, 2026

Choose a reason for hiding this comment

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

브라우저로만 E2E로 테스트하다보니 프론트엔드까지 고려한 설계를 놓쳤네요. 백엔드에서 토큰교환까지 핸들링하도록 보완했습니다!

String redisKey = "RT:" + userId;
String stored = redisTemplate.opsForValue().get(redisKey);

if (stored == null || !stored.equals(refreshToken)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major - 동시성] Token Rotation에서 Redis getdelete 사이에 TOCTOU(Time-of-Check to Time-of-Use) 경합 조건이 있습니다.

두 개의 동시 요청이 같은 Refresh Token으로 get을 호출하면, 둘 다 유효한 값을 읽고 각각 새 토큰 쌍을 발급받을 수 있습니다. Token Rotation의 핵심 보안 속성(1회 사용 후 무효화)이 깨집니다.

Redis의 원자적 연산을 사용하세요:

// 방법 1: getAndDelete (Redis 6.2+, Spring Data Redis 지원)
String stored = redisTemplate.opsForValue().getAndDelete(redisKey);
if (stored == null || !stored.equals(refreshToken)) {
    throw new OnSeoulApiException(ErrorCode.INVALID_REFRESH_TOKEN, ...);
}

// 방법 2: Lua 스크립트로 비교+삭제를 원자적으로 수행

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

토큰 발급시 원자성 보장될 수 있겠네요!
보안 관련 자원에 대해서는 TOCTOU를 고려하여 검사/사용을 원자적으로 처리할 수 있도록 고려하겠습니다.

session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/**").permitAll()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major - 보안] /auth/** 전체를 permitAll()로 설정하여 /auth/logout이 인증 없이 호출 가능합니다.

AuthController.logout@RequestAttribute(required = false) Long userId로 userId를 받는데, 인증되지 않은 요청에서는 JWT 필터가 userId를 설정하지 않아 null이 되고, 로그아웃이 무시(no-op)됩니다. 결과적으로 인증 없이도 204가 반환되어 클라이언트가 로그아웃이 성공했다고 오인할 수 있습니다.

이 문제가 실질적 보안 위협이 되는 시나리오를 구체적으로 설명하면 다음과 같습니다:

  1. Access token 만료 상태에서의 로그아웃 실패: 사용자가 앱을 오래 사용하지 않아 access token이 만료된 상태에서 로그아웃 버튼을 누르는 경우, JwtAuthenticationFilterextractUserIdSafely()가 만료된 토큰에 대해 Optional.empty()를 반환하고, userId request attribute가 설정되지 않습니다. 이때 /auth/logoutpermitAll()이므로 요청 자체는 통과하지만, userId가 null이어서 Redis의 refresh token(RT:{userId})이 삭제되지 않습니다. 클라이언트는 204를 받고 로컬 토큰을 폐기하지만, 서버의 refresh token은 남아 있는 상태가 됩니다.

  2. 토큰 탈취 시 피해 지속: 위 상황에서 만약 refresh token이 이미 탈취된 상태라면, 정당한 사용자가 로그아웃을 시도해도 서버 측 refresh token이 무효화되지 않으므로, 공격자는 탈취한 refresh token으로 /auth/token/refresh를 호출하여 새로운 access token + refresh token 쌍을 계속 발급받을 수 있습니다. 사용자는 로그아웃이 완료되었다고 믿고 있지만, 실제로는 공격자의 세션이 종료되지 않은 상태입니다.

  3. 토큰 로테이션도 우회됨: 현재 refresh 로직에서 토큰 로테이션(기존 RT 삭제 → 새 RT 발급)이 구현되어 있지만, 로그아웃이 실패한 경우 공격자가 먼저 refresh를 호출하면 로테이션이 공격자의 토큰으로 갱신되어, 정당한 사용자의 refresh token만 무효화되는 역전 현상이 발생할 수 있습니다.

개선 방안:

  1. /auth/logoutpermitAll() 범위에서 제외하여 authenticated 경로로 분리하거나
  2. logout 엔드포인트에서 userId가 null이면 401을 반환하도록 변경
.requestMatchers("/auth/token/refresh").permitAll()
// /auth/logout은 authenticated 영역에 포함

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

로그아웃도 인증되도도록 수정했습니다!

@Service
public class AuthService {

private static final long REFRESH_TOKEN_TTL_DAYS = 7L;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Major - 설계] REFRESH_TOKEN_TTL_DAYS = 7L 상수가 AuthServiceOAuth2LoginSuccessHandler 두 곳에 중복 정의되어 있습니다.

또한 JWT 자체의 만료 시간(jwt.refresh-token-minutes: 10080)과 Redis TTL(7일)이 별도로 관리되어, 하나만 변경하면 불일치가 발생합니다. 예를 들어 JWT 만료를 14일로 변경하고 Redis TTL은 7일로 두면, JWT는 유효하지만 Redis에서 삭제된 상태가 됩니다.

권장 사항:

  • REFRESH_TOKEN_TTL_DAYSJwtProviderrefreshTokenMinutes에서 파생하거나 공통 설정 클래스로 추출
  • 두 값이 항상 동기화되도록 단일 소스(single source of truth)를 유지

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

JwtProvider에서 설정하여 사용하도록 수정했습니다.


@RestControllerAdvice
public class GlobalExceptionHandler {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Minor - 기능] OnSeoulApiException만 처리하고 있어, @Valid 검증 실패 시(MethodArgumentNotValidException) Spring 기본 포맷으로 응답됩니다.

AuthController에서 @Valid @RequestBody RefreshRequest가 실패하면 Spring 기본 에러 응답이 반환되어 {"code", "message"} 형식과 일관성이 깨집니다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
    String message = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining(", "));
    return ResponseEntity.badRequest()
            .body(Map.of("code", "INVALID_INPUT", "message", message));
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@Valid 어노테이션에 대한 예외 핸들링 추가했습니다!

client-secret: ${KAKAO_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Minor - 기능] Kakao OAuth2 scope에 profile_nickname만 설정되어 있지만, OAuth2LoginSuccessHandler에서 kakaoAccount.get("email")로 이메일을 읽고 있습니다.

Kakao API에서 이메일을 받으려면 account_email scope가 필요하고, Kakao Developers 콘솔에서 해당 동의 항목도 활성화해야 합니다. 현재 설정으로는 Kakao 사용자의 email이 항상 null이 됩니다.

의도적으로 email을 선택 사항으로 둔 것이라면, OAuth2LoginSuccessHandler에서 Kakao 이메일 접근 코드에 주석을 남겨 의도를 명확히 해주세요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

account_email 활성화한 후 scope 추가했습니다!

try {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Minor - 운영] writeErrorResponse에서 예외를 catch (Exception ignored)로 무시하고 있습니다.

에러 응답 쓰기 자체가 실패하는 경우(예: 이미 committed된 response, 네트워크 오류 등)는 드물지만, 장애 상황에서 디버깅 단서가 완전히 사라집니다. 최소한 로그를 남기는 것을 권장합니다:

} catch (Exception e) {
    log.warn("에러 응답 작성 실패: {}", e.getMessage());
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

반영했습니다.

Comment thread GEMINI.md Outdated
@@ -0,0 +1,116 @@
# CLAUDE.md
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Minor - 문서] 파일명은 GEMINI.md인데 내용의 제목이 # CLAUDE.md로 되어 있어 혼란스럽습니다. 파일명과 제목을 일치시켜주세요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

해당파일 제거하여 반영했습니다.

@@ -0,0 +1,4 @@
package dev.jazzybyte.onseoul.auth.dto;

public record AccessTokenResponse(String accessToken) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Suggestion] AccessTokenResponse record가 정의되어 있지만, 현재 코드베이스 어디에서도 사용되지 않습니다. 향후 사용 계획이 없다면 제거하여 불필요한 코드를 줄이는 것이 좋겠습니다. YAGNI 원칙에 따라 필요할 때 추가하는 편이 낫습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

반영했습니다.

changha and others added 9 commits April 28, 2026 00:34
buildToken()에 tokenType 파라미터를 추가해 Access/Refresh Token이 동일한
서명 구조를 가지는 문제를 해결함. extractUserIdSafely()는 access 타입만,
extractUserIdFromRefreshToken()은 refresh 타입만 허용하도록 검증 추가.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AuthService.refresh()에서 validateToken + extractUserId 조합 대신
extractUserIdFromRefreshToken()을 사용해 Refresh Token 타입을 명시적으로 검증함.
Access Token이 Refresh 자리에 전달되면 Redis 조회 없이 INVALID_TOKEN 예외 발생.

JwtProviderTest, AuthServiceTest에 타입 오용 방지 케이스 추가:
- extractUserIdSafely: Refresh Token 전달 시 empty 반환 (필터 오용 방지)
- extractUserIdFromRefreshToken: Access Token 전달 시 예외 발생
- AuthService.refresh: Access Token 전달 시 Redis 조회 전 예외 발생

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Authorization Code Flow에서 브라우저 콜백은 full-page navigation이므로
JSON body 응답을 SPA가 수신할 수 없는 문제 해결.

변경 내용:
- OAuth2LoginSuccessHandler: JSON 응답 제거 → access/refresh 토큰을
  HttpOnly; Secure; SameSite=Strict 쿠키로 발급 후 프론트엔드 콜백 URL로 리다이렉트
  (SUSPENDED 계정도 에러 파라미터와 함께 리다이렉트)
- JwtAuthenticationFilter: Authorization 헤더 없을 때 access_token 쿠키 폴백 추가
  (브라우저 SPA ↔ API/모바일 클라이언트 모두 지원)
- AuthController.refresh(): @RequestBody → @CookieValue, 새 토큰을 Set-Cookie로 반환
- AuthController.logout(): 쿠키 만료(maxAge=0) 처리 추가 (userId 없어도 쿠키 정리)
- application.yml: app.frontend-base-url, app.cookie-secure 환경변수 추가

테스트: 쿠키 설정·리다이렉트 검증, 헤더 vs 쿠키 우선순위, 만료 사용자 에러 리다이렉트 등

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
쿠키 기반 전환 이유(Authorization Code Flow 설명), 보안 속성(HttpOnly/Secure/SameSite),
각 메서드의 동작과 주의사항(maxAge=0 삭제 조건, path 제한 목적 등) 문서화.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
[TOCTOU 수정]
Redis get + delete 두 번 호출 → getAndDelete 원자적 연산으로 교체.
동시 요청이 같은 Refresh Token으로 둘 다 유효 판정을 받을 수 있던
경합 조건(Time-of-Check to Time-of-Use) 해소.

[TTL 단일 소스]
AuthService / OAuth2LoginSuccessHandler에 중복 정의된 REFRESH_TOKEN_TTL_DAYS
상수를 제거하고 JwtProvider.getRefreshTokenMinutes()에서 파생하도록 통합.
jwt.refresh-token-minutes 값 하나만 변경하면 JWT 만료 · Redis TTL · 쿠키 maxAge가
모두 자동으로 동기화됨.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@VitoJeong VitoJeong merged commit c688395 into on-seoul-api Apr 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants