Skip to content

feat(S15P11A705-63): Google 소셜 로그인과 쿠키 기반 세션 JWT 도입 - #73

Merged
cherry-go-round merged 25 commits into
devfrom
feat/S15P11A705-63-google-oauth-cookie-auth
Jul 29, 2026
Merged

feat(S15P11A705-63): Google 소셜 로그인과 쿠키 기반 세션 JWT 도입#73
cherry-go-round merged 25 commits into
devfrom
feat/S15P11A705-63-google-oauth-cookie-auth

Conversation

@cherry-go-round

Copy link
Copy Markdown
Contributor

요약

Google 소셜 로그인과 쿠키 기반 세션 JWT를 도입합니다. OAuthLoginSuccessHandler가 회원만 확정하고 쿠키 없이 리다이렉트하던 자리(TODO(S15P11A705-63))를 채워, 발급·검증·회전·로그아웃 전체를 붙였습니다. 인증 계약(authentication.md)의 단일 PR 원칙에 따라 의존성·설정·테스트·문서를 함께 냅니다.

Jira (필수)

관련 GitHub Issue (선택)

  • back#28 (인증 스텁 계약 — 이 PR에서 스텁을 제거하며 계약을 이어받음)

변경 사항

발급 (global/security/token)

  • JwtTokenProvider — RS256 서명. sub·iss·exp·jti·token_use 클레임
  • JwtKeyProvider — PKCS#8 PEM 주입. 공개키는 개인키(CRT)에서 유도해 설정 항목을 늘리지 않음. 키가 없으면 로컬·테스트는 임시 키쌍, 운영 프로파일은 기동 실패
  • AuthCookiesaccess_token(Path=/api/core) · refresh_token(Path=/api/core/v1/auth) · logged_in(Path=/, HttpOnly 아님)

검증 (global/security/authentication)

  • JwtAuthenticationFilter — Access 쿠키 → SecurityContext. 실패해도 401을 여기서 쓰지 않고 컨텍스트를 비운 채 통과시켜 오류 응답 형식을 한 곳(entry point)에 유지
  • 검증 알고리즘을 RS256으로 고정(RFC 8725 §3.1). 토큰 헤더의 alg를 신뢰하지 않음
  • MemberPrincipal·@LoginMember·LoginMemberArgumentResolver — principal 계약

회전 (domain/auth)

  • RefreshTokenStorejti당 Redis 키 하나. 소비는 delete의 반환값으로 판정(조회 후 삭제로 나누면 두 요청이 같은 토큰을 동시에 통과)
  • POST /v1/auth/refresh(204, 회전) · POST /v1/auth/logout(204, 멱등)

설정·구조

  • global/security를 책임별 하위 패키지 넷으로 분리 — oauth(공급자와의 흐름) / token(발급) / authentication(요청 검증) / error(401·403 응답). 보안 설정도 global/config에서 이 아래로 이동
  • CsrfCookieFilter 추가 — spa()의 CSRF 토큰이 지연 로딩이라 조회 요청에서 XSRF-TOKEN이 안 나가고, 그러면 클라이언트가 첫 상태 변경 요청에 넣을 토큰을 구할 방법이 없어 영영 403
  • JSpecify @NullMarkedglobal/config·domain/auth/**·security/**로 확대
  • 테스트 컨테이너 기반을 IntegrationContainerSupport 하나로 통합(Postgres + Redis)

문서

  • BD-29(@NullMarked 패키지 단위) · BD-30(인가 요청을 쿠키에) · BD-31(RS256·키 관리) · BI-17(구현 리포트) · BT-04(트러블슈팅)
  • authentication.md §7 구현된 형태 / §8 로컬·테스트 인증 통과 절차, configuration.mdJWT_PRIVATE_KEY, package-structure.md에 보안 패키지 경계

테스트 / 검증

  • ./gradlew clean check --no-daemon
  • DB 변경 시 PostgreSQL 통합 테스트
  • migration 변경 시 빈 DB migration 테스트
  • API 계약 변경 시 관련 문서 갱신
  • 되돌리기 어려운 결정을 포함하면 docs/backend/decisions/에 BD 추가 또는 기존 BD 링크

RED — 구현 전, AuthTokenContractTests를 HTTP 경계로만 작성해(새 타입 참조 없이) 컴파일 실패가 아닌 실제 단언 실패를 먼저 확인했습니다.

$ ./gradlew test --tests '*AuthTokenContractTests*' --no-daemon
인증 토큰 계약 > 콜백이 Access·Refresh·logged_in 쿠키를 계약된 속성으로 내려준다 FAILED
8 tests completed, 7 failed          # "쿠키가 안 내려온다"
BUILD FAILED

logged_in Path 버그(BT-04)도 같은 방식으로 고정했습니다.

Expecting actual: "/api/core"
to be equal to:   "/"
[프론트는 루트 아래에서 실행된다. API 경로로 좁히면 이 쿠키는 쓸모가 없다]

GREEN

$ ./gradlew test --tests '*AuthTokenContractTests*' --tests '*JwtTokenProviderTest*' --tests '*JwtKeyProviderTest*' --no-daemon
BUILD SUCCESSFUL     # 25 tests

Regressiondev 병합 후 HEAD(fa68d3e)에서 실행.

$ ./gradlew clean check --no-daemon
BUILD SUCCESSFUL in 57s
tests: 223  failures: 0     # 이 PR 131개 + dev 도메인 92개

실제 Google 수동 검증 — 동의 화면이 사람의 브라우저를 요구해 CI에 넣을 수 없는 경로입니다(authentication.md §8).

  • OIDC 경로가 동작합니다. 자동 테스트는 scope에서 openid를 빼고 돌아(서명된 id_token·JWKS 검증 우회) OidcUserService 경로에 커버리지가 0건이었습니다. 운영 설정은 openid,email입니다. 실제 계정으로 sub·email이 추출돼 core.social_account에 저장되는 것을 확인했습니다
  • redirect_uri 정확 일치, PKCE S256, nonce 통과
  • Secure 쿠키가 http://localhost에서 왕복됩니다 — 코드 주석에 주장만 있고 검증한 적 없던 지점
  • 이 과정에서 BT-04를 발견했습니다

배경

토큰 모델은 BD-21이 공용 계약으로 확정한 것이고, 이 PR은 비어 있던 서명 알고리즘·키 관리BD-31로 정했습니다.

RS256을 고른 이유를 명확히 해둡니다 — IETF가 요구해서가 아닙니다. RFC 9068이 비대칭을 RECOMMENDED로 걸지만 그 이유가 *"리소스 서버가 검증 정보를 얻는 과정을 단순화"*이고, 발급자·검증자 분리를 전제합니다. 우리는 한 프로세스라 그 이유가 성립하지 않습니다. RFC 8725는 대칭/비대칭에 중립입니다.

실제 이유는 되돌리는 비용의 비대칭입니다. HS256으로 두면 나중에 검증자를 분리할 때 알고리즘·키 배포·발급된 토큰 호환을 한꺼번에 갈아야 하고, RS256이면 공개키를 넘기는 일만 남습니다. 지금 필요해서가 아니라 나중 마이그레이션을 피하려고 내는 선불이며, BD-31의 감수 항목에 "끝까지 한 프로세스로 운영하면 HS256으로 충분했다는 뜻이 된다" 도 함께 적었습니다.

리뷰 포인트

  1. dev 통합 방식 — merge를 썼습니다. PR 스킬 4.2는 rebase를 지시하지만, 이 병합에는 의미 충돌이 5건(마이그레이션 번호, BD·BI 번호, 스텁 중복, YAML 중복 키, 테스트 기반 클래스 이름) 있어 21개 커밋에 걸쳐 반복 해소하는 위험이 컸습니다. 이 저장소에도 Merge branch 'dev' into feat/... 선례가 있습니다(c011cff). 판단이 다르면 알려주세요 — 되돌릴 수 있게 병합 전 상태를 태그로 보관 중입니다.

  2. ⚠️ 마이그레이션 번호가 V3V4로 바뀌었습니다. dev의 V3__core_domain.sql과 충돌해서입니다(git은 충돌로 잡지 않지만 Flyway가 기동을 거부). 이 브랜치를 먼저 받아 V3=social_account가 적용된 로컬 DB는 checksum 검증에 걸립니다docker compose down -v로 초기화가 필요합니다. 공유 환경에 적용된 적은 없습니다.

  3. AuthTestSupport.loginAs 본문만 바꿨습니다. 스텁을 만든 쪽이 "인증 PR은 이 메서드 본문만 바꾼다"고 계약해 둔 대로, spring-security-testauthentication() + csrf()로 교체했더니 도메인 테스트 7개 파일·110여 개 호출부가 한 줄도 바뀌지 않았습니다. csrf()를 함께 넣은 것이 핵심입니다 — 없으면 post·patch·delete 테스트가 전부 403이 됩니다.

  4. 로그아웃의 한계는 감수한 것입니다. Refresh만 무효화하므로 이미 발급된 Access는 최대 30분 유효합니다(BD-21의 "무효화 지연"). 즉시 차단이 요건이 되면 블랙리스트가 필요합니다.

  5. PR이 큽니다 (소스 85개 + 문서). 인증 계약이 단일 PR 병합을 요구해서 쪼갤 수 없습니다. 커밋을 유형별로 나눠 뒀으니(featrefactor ×4 → fix → merge) 커밋 단위로 보시는 편이 나을 수 있습니다.

미결 / 후속

  • ⚠️ 인프라에 JWT_PRIVATE_KEY 주입 요청이 필요합니다. 없으면 운영 파드가 뜨지 않습니다(의도한 fail-fast — 임시 키를 만들면 파드마다 서명 키가 달라져 스케일아웃·재시작 때 전면 로그아웃). RSA 2048+ PKCS#8 PEM. 배포 전 필수입니다
  • 공용 계약 Team-PinLog/docsstatic/08_API_명세.md §1.1·2.1·3.2~3.5 개정 — 별도 저장소라 이 PR 밖입니다
  • 키 회전 — kid만 선반영했고 다중 키 검증은 없습니다. 지금 키를 바꾸면 전면 로그아웃입니다
  • Kakao·Naver 소셜 로그인은 S15P11A705-64, 회원 탈퇴는 S15P11A705-65로 분리되어 있습니다

cherry-go-round and others added 22 commits July 28, 2026 11:06
인증 수단을 신원(member)과 분리해 보관한다(06 §2.2). 소셜 로그인 콜백이
기존 회원 여부를 판정할 근거이며, 인증 골격의 첫 조각이다.

- V103__social_account.sql — 파트별 구간을 두지 않고 현재 적용된 최대 번호
  보다 큰 값을 쓴다. 구간을 나누면 AI 구간(V100~)이 먼저 적용된 상태라 낮은
  번호가 항상 순서를 위반해 Flyway 기동이 실패한다(#27 논의)
- 활성행 기준 부분 유니크 ux_social_account_provider_user — 전체 유니크로
  두면 탈퇴 후 같은 소셜 계정으로 재가입할 수 없다(07_ERD §4.1)
- 회원 단위 조회(마이페이지 요약·탈퇴)용 부분 인덱스
- SocialProvider(GOOGLE·KAKAO·NAVER), SocialAccount 엔티티, 리포지토리
- SocialAccount 클래스 주석에 탈퇴 시 repository.delete() 금지 근거를 남겼다.
  @SQLDelete로 removed 상태가 되면 마스킹 변경이 flush되지 않고
  @SQLRestriction 때문에 다시 조회할 수도 없다

검증(PostgreSQL Testcontainers):
- 마이그레이션 컬럼 7개 일치, 유니크가 deleted_at IS NULL 부분 조건인지 확인
- 활성 중복 거부, 소프트 삭제 후 동일 계정 재가입 성공
- email null 허용(공급자 미제공·미동의)
- 소프트 삭제 후 물리 행 잔존 + 조회 제외

RED: 컴파일 실패(cannot find symbol: SocialAccount) 확인 후 구현
GREEN: 대상 테스트 통과
Regression: ./gradlew clean check --no-daemon 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- SocialAccountPersistenceTests·FlywayMigrationTests에 클래스·메서드
  @DisplayName 부여. FlywayMigrationTests는 기존 2개에도 함께 넣어
  같은 파일 안에서 절반만 이름이 붙는 상태를 피했다
- emailIsOptional이 실제로는 아무것도 검증하지 않고 있었다. save()가
  돌려주는 인스턴스의 필드를 그대로 확인해 DB를 거치지 않았다.
  saveAndFlush + native 조회로 바꿔 (1) email에 NOT NULL이 없다는 것과
  (2) 저장된 행이 실제로 NULL이라는 것을 검증한다

이 레포에 @DisplayName 선례가 없어 이번에 건드린 두 파일에만 적용했다.
나머지 5개 테스트 클래스는 아직 없는 상태다.

검증: ./gradlew clean check --no-daemon 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
의존성을 넣는 순간 기본 필터 체인이 모든 요청을 잠그므로 SecurityConfig와
오류 응답 처리를 같은 슬라이스에 담았다. OAuth 공급자 등록은 다음 단계다.

의존성:
- spring-boot-starter-security, -security-oauth2-client, -security-test.
  Boot 4에서 spring-boot-starter-oauth2-client가 -security-oauth2-client로
  이름이 바뀌었다. ClientRegistration이 없으면 자동 설정이 비활성이라
  자격증명 없이도 기동된다

SecurityConfig:
- 공개 경로는 actuator health·prometheus, /v1/auth/**, api-docs·swagger.
  헬스체크가 막히면 파드가 뜨지 않는다
- SessionCreationPolicy.STATELESS. 인증 상태는 쿠키의 JWT가 들고 있다
- CSRF는 Security 7의 CsrfConfigurer::spa 사용
- formLogin·httpBasic·logout 비활성. 기본 401이 폼 리다이렉트나
  WWW-Authenticate로 나가지 않게 한다

오류 응답:
- Security 필터 체인은 DispatcherServlet 이전이라 GlobalExceptionHandler를
  타지 않는다. 그대로 두면 401·403이 본문 없이 나가 응답 계약이 깨지므로
  RestAuthenticationEntryPoint·RestAccessDeniedHandler가 같은 envelope를
  직접 쓴다(SecurityErrorWriter). Jackson 3(tools.jackson)이 활성이다
- ErrorCode에 UNAUTHORIZED(401)·FORBIDDEN(403) 추가. 403은 CSRF 전용이고
  타인 자원 접근은 존재를 숨기기 위해 404를 쓴다(08 §1.2)

TraceIdFilter:
- @order(HIGHEST_PRECEDENCE) 추가. Security 체인은 -100에 등록되는데 순서
  없는 @component 필터는 LOWEST_PRECEDENCE라 뒤에 돌아, Security가 직접
  쓰는 401 응답의 traceId가 null이 되고 있었다

계약 변경:
- DeploymentContractTests의 /api/core/not-found가 404 → 401. 미인증과
  "없는 경로"를 구분하지 않는다

검증: ./gradlew clean check --no-daemon 통과
RED: 401·403·envelope·CSRF 4건 실패 확인 후 구현

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
소셜 로그인 진입이 Google 인가 페이지로 넘어가는 데까지. 콜백 처리와
토큰 발급은 다음 슬라이스다.

설정:
- spring.config.import로 .env를 프로퍼티로 올린다. Spring Boot는 .env를
  자동으로 읽지 않아 지금까지 compose만 이 파일을 쓰고 있었다. optional:이라
  .env가 없는 CI·운영에서도 기동이 깨지지 않는다
- forward-headers-strategy: framework. Traefik 뒤에서 X-Forwarded-*를 믿지
  않으면 redirect_uri가 파드 내부 주소로 만들어져 redirect_uri_mismatch가 난다
- Google ClientRegistration. client-id 기본값 unset은 자격증명 없이도 컨텍스트가
  뜨게 해 CI가 리다이렉트를 검증할 수 있게 한다

경로:
- redirect-uri를 {baseUrl}/v1/auth/{registrationId}/callback으로 둬 명세 3.2
  경로를 그대로 쓴다. registrationId가 마지막 세그먼트가 아니어도 되는 이유는
  콜백이 경로가 아니라 저장된 authorization request에서 값을 꺼내기 때문이다
  (OAuth2LoginAuthenticationFilter.attemptAuthentication)
- 반면 인가 엔드포인트는 {baseUri}/{registrationId}로만 매칭해 /auth/{provider}/login을
  만들 수 없다. SocialLoginController가 받아 내부 경로로 넘긴다
- 지원하지 않는 provider는 404(RESOURCE_NOT_FOUND)

PKCE:
- 기본 resolver는 client secret이 있는 confidential client에 PKCE를 넣지 않아
  OAuth2AuthorizationRequestCustomizers.withPkce()로 명시적으로 켰다(RFC 9700)

미검증·부채:
- redirectionEndpoint baseUri의 경로 중간 와일드카드는 기동이 거부되지 않은
  것까지만 확인됐다. 실제 매칭은 콜백 슬라이스에서 판가름난다
- 기본 AuthorizationRequestRepository가 HttpSession을 쓴다. STATELESS 선언과
  어긋나므로 콜백 전에 쿠키 기반 구현으로 교체해야 한다

검증: ./gradlew clean check --no-daemon 통과
RED: 리다이렉트·redirect_uri·PKCE 3건 실패 확인 후 구현

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
로그인 진입부터 콜백까지 한 흐름이 동작한다. 공급자는 JDK 내장 HttpServer로
대역화해 CI에서도 돈다(StubOAuthProvider).

콜백 처리:
- OAuthUserInfo — 공급자 응답을 (provider, providerUserId, email)로 정규화.
  식별은 공급자 발급 식별자로만 하고 이메일·닉네임을 쓰지 않는다(06 2.2).
  Kakao·Naver는 응답이 한 겹 감싸여 있어 해당 티켓에서 추출을 구현한다
- SocialLoginService — 활성 social_account가 있으면 로그인, 없으면 이 시점에
  member+social_account 생성. 약관 동의는 클라이언트 전담이라 서버는 관여하지 않는다
- OAuthLoginSuccessHandler — 회원 확정 후 클라이언트 복귀 경로로 리다이렉트
- OAuthLoginFailureHandler — 실패 사유를 노출하지 않고 ?error=OAUTH_FAILED로 복귀

정규화를 사용자 정보 서비스가 아니라 성공 핸들러에 둔 이유:
openid scope 유무에 따라 Spring이 OidcUserService와 DefaultOAuth2UserService로
갈라지는데, 성공 핸들러는 두 경우 모두 같은 OAuth2AuthenticationToken을 받아
분기가 생기지 않는다.

앞 커밋에서 남긴 두 부채를 해소했다:
- redirectionEndpoint의 경로 중간 와일드카드가 실제로 매칭됨을 콜백 테스트로 확인
- CookieOAuth2AuthorizationRequestRepository로 세션 저장소를 대체. JSESSIONID가
  발급되지 않음을 테스트로 고정했다. 쿠키 역직렬화에는 ObjectInputFilter 허용
  목록을 걸어 조작된 쿠키를 통한 gadget 공격을 막는다

남은 것:
- 성공 핸들러가 회원만 만들고 인증 쿠키를 발급하지 않는다(TODO 표시)
- 테스트는 openid를 빼고 돌아 OIDC 경로(id_token 서명·JWKS)를 타지 않는다.
  운영과 다른 코드 경로이므로 stub에 서명을 추가해 좁힐 예정이다
- 실제 Google과는 통신하지 않았다. 자격증명 유효성과 콘솔 redirect_uri 등록은
  별도 확인이 필요하다

검증: ./gradlew clean check --no-daemon 통과
RED: 회원 생성·복귀 경로·세션 미생성 3건 실패 확인 후 구현

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…표기 정리

Spring Security 7이 자기 패키지를 @NullMarked로 선언한다. 우리 패키지를 마킹하지
않으면 구현체의 파라미터가 "nullness 미상"으로 남아, non-null 파라미터를 재정의할 때
보증이 약해진다는 경고가 난다. 파라미터마다 @nonnull을 붙이는 대신 패키지를 마킹했다.

- global/security/package-info.java 신설(@NullMarked). 패키지 내 6개 클래스가
  한 번에 해결된다
- CookieOAuth2AuthorizationRequestRepository — 인터페이스가 @nullable로 선언한
  loadAuthorizationRequest·removeAuthorizationRequest 반환에 표기를 맞췄다.
  saveAuthorizationRequest의 첫 파라미터는 인터페이스가 non-null이지만 Spring 기본
  구현처럼 null을 "지우라"로 받으므로, 오버라이드에서 nullability를 넓혀 @nullable을 붙였다
- SecurityErrorWriter.traceId() — MDC.get()은 null을 돌려줄 수 있다. 마킹 후에는
  표기가 없으면 non-null 선언이 되어 거짓 보증이 되므로 @nullable을 붙였다
- build.gradle에 org.jspecify:jspecify 명시. 애노테이션을 직접 import 하므로
  전이 의존에 기대지 않는다. 버전은 Boot BOM이 관리한다

좁히기(narrowing) 위반이 없는지 확인했다. 구현하는 4개 인터페이스
(AuthenticationEntryPoint·AccessDeniedHandler·AuthenticationSuccessHandler·
AuthenticationFailureHandler)에 @nullable 파라미터가 하나도 없다.

범위 밖:
- domain/member/repository는 Spring Data가 @NullMarked이나 CrudRepository에
  @nullable이 없어 우리 시그니처가 이미 계약과 일치한다. 손댈 것이 없다
- global/web(TraceIdFilter)도 같은 경고가 나지만, 같은 패키지의
  ApiResponseBodyAdvice가 @nullable body를 받아 함께 감사해야 한다. 두 파일 모두
  다른 파트가 만든 것이라 별도로 합의한다

검증: ./gradlew clean check --no-daemon 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
f026b15의 결정 근거와 검토한 대안을 남긴다.

- 경고의 근본 원인이 "구현 쪽 미마킹"이라 파라미터별 @nonnull(안 b)이 아니라
  패키지 마킹(안 d)을 택한 이유
- 레포 전체 마킹(안 e)을 미룬 이유 — global/web의 ApiResponseBodyAdvice가
  @nullable body를 받아 다른 파트와 함께 감사해야 한다
- 마킹하며 바로잡은 두 곳(SecurityErrorWriter.traceId, saveAuthorizationRequest
  파라미터 넓히기)과 좁히기 위반이 없음을 확인한 근거
- 재검토 트리거 3건

decisions/README 목록과 WORKLOG에도 한 줄씩 추가했다.
번호는 dev 머지 기준 다음 값이며, 머지 순서에 따라 바뀔 수 있다(README 규칙).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dev의 BD-26이 "버전 구간 소유를 유지하고 out-of-order를 허용"으로 확정됐다.
내 브랜치는 구간을 두지 않는다는 전제로 작성돼 있어 어긋났다.

- V103__social_account.sql → V3__social_account.sql. 백엔드 소유 구간은 V2~V99이며
  V103은 AI 구간을 침범한 상태였다. 파일 주석의 번호 근거도 BD-26 기준으로 고쳤다
- FlywayOutOfOrderTests가 백엔드 몫으로 core.member 하나만 되돌리고 있었다.
  social_account가 member를 FK로 참조하면서 DROP이 실패해 테스트가 깨졌다.
  되돌릴 버전·테이블을 목록으로 바꾸고 참조하는 쪽부터 지우도록 일반화했다.
  memberTableExists를 tableExists(url, table)로 일반화해 두 테이블을 모두 확인한다

이 테스트는 백엔드 마이그레이션이 늘 때마다 목록 갱신이 필요하다. 그 사실을
상수 주석에 남겼다.

검증: ./gradlew clean check --no-daemon 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7f1e8ed·1ed1dbe에서 내린 결정의 근거가 CookieOAuth2AuthorizationRequestRepository
javadoc에만 있어 뒤늦게 남긴다. CONTRIBUTING의 "결정은 코드보다 먼저 기록"을 지키지
못했으므로 헤더에 작성 시점을 밝혔다.

- SessionCreationPolicy.STATELESS 선언과 기본 구현
  (HttpSessionOAuth2AuthorizationRequestRepository)의 충돌이 출발점
- Redis + 키 쿠키(안 d)를 물린 이유 — 로그인 진입 가능 여부를 Redis 장애에
  묶는 대가가 짧은 상태를 서버가 통제해 얻는 값보다 크다
- 쿠키를 서명하지 않은 실제 대가는 무결성이 아니라 역직렬화 DoS 표면이라는 점.
  공격자가 정상 발급받은 쿠키를 심는 login CSRF는 서명해도 성립하고,
  역직렬화 필터에 maxdepth·maxbytes가 없다
- Secure를 request.isSecure()에 맡긴 것과 forward-headers-strategy 의존
- BD-21(토큰 쿠키)과 다른 결정임을 맥락에 명시 — 담기는 것·수명·위협 모델이
  다르다. Path가 Refresh 쿠키와 같은 이유도 함께 적었다
- 아직 회귀로 고정되지 않은 것: 쿠키 속성(HttpOnly·SameSite·Path)과
  조작된 쿠키의 fail-closed 동작

decisions/README 목록과 WORKLOG에도 한 줄씩 추가했다.
번호는 dev 머지 기준 다음 값이며(BD-26까지 머지), 머지 순서에 따라 바뀔 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BD-21이 토큰 모델(JWT, Access 30분 / Refresh 7일, HttpOnly 쿠키)까지만 정하고
서명 알고리즘과 키 관리를 비워 뒀다. 발급을 구현하려면 먼저 필요하다.

먼저 외부 규범이 이 선택을 정해 주는지 확인했고, 정해 주지 않는다.

- RFC 9068이 비대칭을 RECOMMENDED로 걸지만 권고 이유가 "리소스 서버가 검증 정보를
  얻는 과정을 단순화"다. 발급자와 검증자의 분리가 전제인데 우리는 한 프로세스라
  이 이유가 성립하지 않는다. 규정하는 "OAuth access token"도 우리 세션 쿠키와 다르다
- RFC 8725(BCP 225)는 대칭/비대칭에 중립이다. 선택이 아니라 다루는 방법을 규정한다
- draft-ietf-oauth-browser-based-apps(-27, 아직 RFC 아님)는 BFF+쿠키 세션을 우리와
  같은 구조로 다루지만 서명 알고리즘은 규정하지 않는다

그래서 "IETF가 요구해서"로 적지 않았다. 사실이 아니다. 실제 이유는 되돌리는 비용의
비대칭이다 - HS256으로 두면 나중에 검증자를 분리할 때 알고리즘·키 배포·발급된 토큰
호환을 한꺼번에 갈아야 하고, RS256이면 공개키를 넘기는 일만 남는다. 지금 필요해서가
아니라 나중 마이그레이션을 피하려고 내는 선불이며, 감수 항목에 "끝까지 한 프로세스로
운영하면 HS256으로 충분했다는 뜻이 된다"도 함께 적었다.

함께 정한 것:

- 키 주입은 환경변수 PEM(기존 DB_PASSWORD 경로 재사용). 새 메커니즘을 만들지 않는다
- 키 미주입 시 로컬·테스트는 임시 키쌍 생성, 운영은 기동 실패. 더미 기본값을 PEM에
  쓸 수 없고, 임시 생성을 운영에 열면 파드마다 키가 달라져 스케일아웃·재시작 때
  전면 로그아웃이 된다
- kid는 회전 미구현이어도 선반영. 없으면 나중 회전이 전면 로그아웃을 강제한다
- JWKS 엔드포인트는 두지 않는다. 검증자가 자기 자신이라 쓸 곳이 없다
- 검증 시 알고리즘 고정(RFC 8725 3.1). 토큰 헤더의 alg를 신뢰하지 않는다

라이브러리(nimbus)는 트레이드오프가 "jjwt가 API가 조금 간결하다" 한 줄뿐이라
별도 BD를 만들지 않고 이 문서에 한 줄로 흡수했다. 기본값 수용이다.

번호는 dev 머지 기준 다음 값이며 머지 순서에 따라 바뀔 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OAuthLoginSuccessHandler가 회원만 확정하고 쿠키 없이 리다이렉트하던 자리
(TODO(S15P11A705-63))를 채웠다. 그 한 줄이 OAuth Client 역할의 끝과
BFF·리소스 서버 역할의 시작 사이 경계였다. 근거는 BD-21·BD-29.

발급
- JwtTokenProvider: RS256 서명. sub(memberId)·iss·exp·jti·token_use 클레임
- JwtKeyProvider: PKCS#8 PEM 주입. 공개키는 개인키(CRT)에서 뽑아 설정 항목을
  둘로 늘리지 않는다. 키가 없으면 로컬·테스트는 임시 키쌍, 운영은 기동 실패
- kid는 공개키 thumbprint에서 뽑는다. 난수면 같은 키를 쓰는 파드끼리 값이
  달라져 나중에 회전을 붙일 때 쓸모없어진다
- AuthCookies: access_token(Path=/api/core)·refresh_token(Path=/api/core/v1/auth)·
  logged_in(HttpOnly 아님). Secure는 로컬에서도 끄지 않는다 - 브라우저는
  localhost를 신뢰할 수 있는 오리진으로 취급해 그대로 보낸다

검증
- JwtAuthenticationFilter: Access 쿠키 -> SecurityContext. 실패해도 여기서 401을
  쓰지 않고 컨텍스트를 비운 채 통과시킨다. 오류 응답 형식이 한 곳(entry point)에만
  있어야 envelope 계약이 어긋나지 않는다
- 검증 알고리즘을 RS256으로 고정한다(RFC 8725 3.1). 토큰 헤더의 alg를 따라가면
  공개키를 HMAC 비밀로 쓰는 alg confusion이 열린다
- token_use로 Access와 Refresh를 가른다. 같은 키로 서명하므로 이 구분이 없으면
  30분짜리가 7일짜리 재발급 권한을 갖는다
- MemberPrincipal·@LoginMember·LoginMemberArgumentResolver로 principal 계약을
  고정했다. Entity를 principal로 노출하지 않고 서비스는 Long memberId를 받는다

회전
- RefreshTokenStore: jti당 Redis 키 하나. 회원당 하나면 두 기기 로그인 시 한쪽
  재발급이 다른 쪽을 끊는다. 소비는 delete의 반환값으로 판정한다 - 조회 후 삭제로
  나누면 두 요청이 같은 토큰을 동시에 통과시킬 수 있다
- POST /v1/auth/refresh(204, 회전) · POST /v1/auth/logout(204, 멱등).
  로그아웃에서 401을 내면 클라이언트가 쿠키를 못 지운 채 남는다

구현하다 드러난 것 둘
- CsrfConfigurer.spa()만으로는 클라이언트가 CSRF 토큰을 얻을 수 없다. 토큰이 지연
  로딩이라 조회 요청에서 XSRF-TOKEN이 안 나가고, 그러면 첫 상태 변경 요청이 영영
  403이다. 기존 SecurityContractTests가 음성 경로(토큰 없으면 403)만 보고 있어
  드러나지 않았고 /auth/refresh 정상 경로를 처음 테스트하며 잡혔다 -> CsrfCookieFilter
- Filter 빈은 서블릿 체인에도 자동 등록된다. JwtAuthenticationFilter를 @component로
  뒀더니 Security 체인 안팎에 두 번 걸렸다. 그 상태로도 테스트는 통과했지만 체인 밖
  인스턴스가 SecurityContextHolderFilter보다 먼저 도는 순서 의존 버그라 빈에서 뺐다

로그인이 Redis에 의존하게 되어(GoogleLoginCallbackTests가 깨져 드러남)
PostgresRedisContainerSupport를 만들었다. BD-21이 감수한 Redis 의존 범위가
재발급에서 로그인까지 넓어진 셈이다.

RED: AuthTokenContractTests를 HTTP 경계로만 작성해(새 타입 참조 없이) 구현 전
8개 중 7개가 컴파일 실패가 아니라 "쿠키가 안 내려온다"는 단언 실패로 떨어지는 것을
먼저 확인했다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
authentication.md
- 완료 조건 체크리스트를 실제 상태로 갱신. 남은 둘의 이유를 함께 적었다:
  404(타인 자원 접근)는 소유자 있는 도메인 리소스가 이 브랜치에 없어 검증 대상이
  없고, 공용 API 명세 개정은 별도 저장소라 이 PR 밖이다
- envelope opt-out 항목은 불필요함이 이미 확인됐으므로 취소선으로 남겼다
  (지우지 않는다 - 왜 필요 없는지가 계약의 일부다)
- 7장 신설: 구현된 형태. 토큰·키·쿠키 표, Refresh 회전 방식, CSRF 토큰을
  클라이언트가 얻는 방법, principal 사용법
- 8장 신설: 로컬 개발과 테스트에서 인증 통과하기. 우회 스위치가 없으므로 모두
  실제 인증 경로를 타는 방법이다. 로컬 PEM 생성 명령과 CookieManager 함정
  (http 링크에 Secure 쿠키를 안 싣는다)을 포함했다
- 도메인 브랜치(S15P11A705-67)의 X-Debug-Member-Id 스텁을 병합 때 반드시
  버려야 한다는 경고를 principal 절에 박았다

configuration.md
- "인증이 추가로 요구하는 비밀값" 절 신설. JWT_PRIVATE_KEY만 기동을 막고
  GOOGLE_* 둘은 unset으로도 기동된다는 차이를 표로 갈랐다
- 인프라 계약에 아직 없는 값이므로 배포 전에 요청해야 한다는 사실을 명시했다
- 기본값을 비워 둔 이유(placeholder 해석 실패로 죽이면 원인이 스택트레이스에만
  남는다)도 적었다

BI-08은 판단한 것과 구현하다 드러난 것을 갈라 적었다. 후자 넷:
CSRF 지연 로딩, Filter 빈 이중 등록, nimbus의 60초 시계 오차 관용,
로그인의 Redis 의존.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
동작을 바꾸지 않았고 131개 통과는 그대로다. 순증 -142줄.

재사용
- JwtAuthenticationFilter: 쿠키 배열 수동 순회 -> WebUtils.getCookie()
- JwtKeyProvider: RSAKey를 두 번 빌드해 thumbprint를 계산하던 것을
  nimbus의 keyIDFromThumbprint() 한 번으로
- AuthTokenContractTests: sub 클레임을 JSON 정규식으로 긁던 것을
  SignedJWT.parse()로. 테스트가 프로덕션과 같은 파서를 쓴다

단순화
- JwtTokenProvider: Long.parseLong(sub)을 검증용·반환용으로 두 번 하던 중복 제거.
  parse()가 VerifiedToken을 돌려주므로 호출자가 변환을 다시 하지 않는다
- SocialLoginTestSupport 신설. AuthTokenContractTests와 GoogleLoginCallbackTests가
  스텁 공급자 설정(@DynamicPropertySource 8줄)과 로그인->인가->콜백 리다이렉트
  추적을 각자 복제하고 있었다. 콜백 경로나 state 전달이 바뀌면 두 곳을 고쳐야 했다

추상화 수준
- AuthTokenController가 "Refresh 쿠키가 없으면 무슨 뜻인가"를 판단하고 있었다
  (refresh는 401, logout은 무시). 토큰의 의미는 서비스가 소유해야 하므로
  AuthTokenService로 내렸다. 쿠키 부재와 검증 실패를 같은 결과로 묶는다 -
  둘을 가르는 의미가 호출자에게 없다

건드리지 않은 것
- SocialLoginRedirectTests: followUntilProvider가 위 base와 비슷해 보이지만 이
  테스트는 스텁이 아니라 실제 Google 설정으로 인가 URL을 검증하는 것이 목적이다.
  공용 base를 물리면 스텁 설정이 강제돼 검증 목적이 사라진다
- Access 토큰의 jti: 회전 추적에 쓰이지 않지만 로그 상관관계와 향후 블랙리스트의
  자리라 남겼다
- AuthCookies.write(response, access, refresh): TokenPair를 받으면 짧아지지만
  global이 domain에 의존하게 된다

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BI-08은 보존 구역이므로 기존 서술을 고치지 않고 "리팩터" 절을 덧붙였다.

핵심은 후속 작업자가 알아야 할 규칙 하나다: 인증 흐름을 테스트할 때는
SocialLoginTestSupport를 상속한다. 단 SocialLoginRedirectTests는 예외이며
그 이유(스텁이 아니라 실제 Google 설정으로 인가 URL을 검증하는 것이 목적)를
함께 적었다. 이유를 안 적으면 다음 사람이 "일관성"을 이유로 붙여 버린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BD-27이 global/security만 @NullMarked로 선언해 둔 상태였다. 그 기준
("전수 감사 비용이 작고 다른 파트 소유 파일이 안 섞일 것")을 그대로 적용해
global/config와 domain/auth/{controller,dto,service,exception}을 추가로 마킹했다.
domain/auth는 전 파일이 이 티켓 산출물이고, global/config는 타 티켓 파일 3개 중
2개가 빈 설정 클래스라 감사가 실제로 작았다.

출발점은 마킹하지 않으면 @nullable이 아무 의미도 없다는 것이다.
JwtProperties.privateKey에 표기를 붙여 뒀지만 global/config가 미마킹이라
도구가 무시하고 있었고, JwtKeyProviderTest의 properties(null) 경고로 드러났다.

마킹이 드러낸 구멍 셋

- JwtKeyProvider 생성자: hasPrivateKey()로 검사하고 fromPem(properties.privateKey())에
  nullable을 non-null 자리로 넘기고 있었다. 술어 메서드는 검사와 사용의 연결을
  컴파일러에 알려 주지 못한다. 지역 변수 + 흐름 검사로 바꾸고 술어를 지웠다
- OAuthUserInfo.email: javadoc은 "null이다"라고 적고 타입은 non-null이었다.
  거짓 보증이다
- OAuthUserInfo.providerUserId: stringValue(attributes.get("sub"))가 nullable을
  돌려주는데 non-null 컴포넌트에 넣고 있었다

*** 세 번째는 동작 변경이다 ***

requiredStringValue를 두어 sub가 없으면 진입점에서 IllegalStateException으로
끊는다. 전에는 조용히 통과해 provider_user_id NOT NULL 위반으로 DB까지 내려가서야
터졌다. Spring이 user-name-attribute: sub 설정으로 앞단에서 걸러 주긴 하지만
그 보증이 설정에만 있고 타입에는 없어, 설정이 바뀌면 조용히 뚫린다.

함께 바로잡은 것
- ApiResponseOpenApiCustomizer의 wrapContent(Content)·isSuccessStatus(String):
  이미 null 방어 분기를 갖고 있는데 표기가 없어 마킹 시 거짓 보증이 되는 자리다.
  BD-27이 "마킹의 실익"으로 든 것과 같은 형태다
- JwtKeyProviderTest.properties(String) -> @nullable. 키 미주입을 재현하려면
  null을 넣어야 하므로 파라미터도 nullable이다

건드리지 않은 것
- global/web·global/response·global/common. BD-27이 명시적으로 제외한 범위이고
  이 티켓 산출물도 아니다. BD-27의 재검토 트리거("마킹 패키지가 늘어 혼재가
  부담이 될 때 -> 레포 전체 도입 논의")에 근접했으나, 전체 도입은 다른 파트 소유
  파일까지 감사해야 하므로 이 PR 범위 밖으로 판단했다

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
한 패키지에 14개 클래스가 쌓여 응집이 무너졌다. 무엇이 어디 있는지 파일 목록만
봐서는 알 수 없는 상태였다.

  oauth          공급자와의 OAuth2 흐름 (OAuth 클라이언트 역할)  로그인 진입·콜백
  token          세션 토큰 서명·검증과 쿠키 전달 (BFF 역할)      로그인 성공·재발급
  authentication 요청을 인증 주체로 변환, principal 계약          모든 요청
                 (리소스 서버 역할)
  error          필터 체인이 직접 만드는 401·403 응답             인증·인가 실패

경계의 근거는 역할이다. 이 티켓 내내 "OAuth Client 역할의 끝과 BFF 역할의 시작이
어디냐"를 따져 왔고(그 경계가 OAuthLoginSuccessHandler의 TODO 자리였다),
그 답을 패키지로 굳혔다.

발급(token)과 검증(authentication)을 더 가른 기준은 수명과 호출 빈도다.
발급은 로그인 시점에 한 번, 검증은 모든 요청에서 돈다. 같이 두면 "요청마다 도는
코드"와 "가끔 도는 코드"가 섞여 성능·보안 검토의 초점이 흐려진다.

나누기 전에 의존이 단방향임을 확인했다 - oauth -> token, authentication -> token,
error는 독립. 역방향 참조가 생기면 경계가 틀린 것이므로 클래스를 옮길 자리를
다시 봐야 한다.

CsrfCookieFilter는 루트에 남겼다. CSRF는 세션 토큰과 다른 관심사라 token에 넣으면
패키지 이름이 거짓말이 되고, 나머지 셋에도 속하지 않는다. 억지로 끼워 넣는 것보다
"어디에도 안 속한다"를 드러내는 편이 낫다.

*** 하위 패키지마다 package-info에 @NullMarked를 다시 선언했다 ***

패키지 애노테이션은 하위 패키지로 상속되지 않는다. 이걸 빠뜨리면 직전 커밋에서
붙인 마킹이 파일 이동만으로 조용히 사라지고, 컴파일은 통과하기 때문에 눈치채기
어렵다. 루트 package-info에도 이 사실과 하위 패키지 지도를 적었다.

테스트도 대상과 같은 경로로 옮겼다(package-structure.md의 테스트 패키지 규칙).
JwtTokenProviderTest·JwtKeyProviderTest -> global/security/token.
SecurityContractTests는 체인 전체를 보므로 루트에 남는다.

package-structure.md의 "인증·보안 경계" 절이 아직 "지금 만들지 않습니다" 상태여서
실제 구조와 하위 패키지 추가 시 주의사항으로 갱신했다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
설정과 그 설정이 조립하는 구현이 떨어져 있으면 한쪽만 고치게 된다.
직전 커밋에서 security를 책임별로 나눠 놓고 정작 그것들을 조립하는 설정은
다른 패키지에 남아 있었다.

  SecurityConfig  -> security/                하위 패키지 넷을 필터 체인으로
                                              조립하는 유일한 지점이라 루트
  JwtProperties   -> security/token/          소비자(JwtKeyProvider·JwtTokenProvider·
                                              AuthCookies)와 같은 패키지
  WebMvcConfig    -> security/authentication/LoginMemberArgumentResolverConfig

WebMvcConfig는 이름도 바꿨다. 내용이 @LoginMember 리졸버 등록 하나뿐인데 이름은
일반 MVC 설정처럼 보여, 다음 사람이 CORS나 인터셉터를 여기 추가하게 만든다.
WebMvcConfigurer는 여러 개가 공존하므로 일반 MVC 설정이 필요해지면 global/config에
따로 만들면 된다. 하나의 거대한 configurer로 모으지 않는다.

global/config에는 도메인·보안과 무관한 셋만 남았다 - ApiResponseOpenApiCustomizer,
JpaAuditingConfig, OpenApiConfig.

두 패키지의 package-info와 package-structure.md를 실제 구조에 맞게 고쳤다.
JwtProperties가 옮겨 가면서 global/config의 package-info가 들던 예시
("JwtProperties.privateKey처럼 null이 정상값인 지점")가 더 이상 그 패키지에 없어
ApiResponseOpenApiCustomizer 기준으로 다시 썼다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
compose.yaml의 postgres에는 org.springframework.boot.service-connection 라벨이
있는데 redis에는 없어 등록 방식이 서로 달라 보였다. 같은 형태로 맞췄다.

표준 redis 이미지라 Boot가 이름으로 자동 인식하므로 동작에는 차이가 없다.
postgres는 pgvector/pgvector가 인식 대상이 아니라 라벨이 필수였고 redis는
아니었을 뿐인데, 그 차이가 파일에 드러나지 않아 "redis는 왜 없지"를 다시
확인하게 만든다.

확인하다 테스트 컨테이너 태그가 compose와 어긋난 것을 발견해 함께 맞췄다.
PostgresRedisContainerSupport가 부동 태그 redis:7-alpine을 쓰고 있어 compose의
redis:7.4.5-alpine과 다른 판으로 갈릴 수 있었다. postgres 쪽은 "테스트 컨테이너도
같은 태그를 쓴다"가 이미 주석으로 고정된 규약인데 redis를 추가할 때 그걸 따르지
않았다.

digest 핀은 붙이지 않았다. postgres는 운영과 같은 이미지를 쓰기 때문에 고정했지만
(S15P11A705-46), 운영 Redis는 인프라가 클러스터 서비스로 관리해 이 파일의 이미지와
무관하다. 필요해지면 인프라와 버전을 맞춘 뒤 함께 핀한다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
담고 있는 것을 이름에 나열하면 의존이 늘 때마다 클래스 이름과 모든 상속 선언이
따라 바뀐다. Redis 하나 추가하자마자 PostgresContainerSupport /
PostgresRedisContainerSupport 둘로 갈라졌고, 다음 의존이 오면
PostgresRedisKafkaContainerSupport가 될 참이었다.

둘을 IntegrationContainerSupport 하나로 합쳤다. 새 의존이 생기면 여기에 컨테이너를
하나 더 추가하면 되고 이름은 그대로다.

Redis를 쓰지 않는 테스트에도 항상 띄운다. 이유는 비용이 아니라 판단이다 -
"이 테스트에 Redis가 필요한가"를 매번 따져야 하고, 틀리면 증상이 엉뚱한 곳에서
나온다. 실제로 토큰 발급을 붙였을 때 GoogleLoginCallbackTests가 콜백에서 깨지며
그 사실을 알려 줬다. 대가는 JVM당 컨테이너 하나다.

부수 효과로 management.health.redis.enabled=false 우회를 8곳에서 제거했다.
Redis가 없어서 헬스 인디케이터를 끄고 있던 것이라 이제 필요 없다. 그만큼
/actuator/health 집계가 운영과 같은 구성으로 검증된다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deprecated
  Testcontainers 2.x에서 org.testcontainers.containers.PostgreSQLContainer가
  org.testcontainers.postgresql.PostgreSQLContainer로 옮겨졌다. 이전 빌드 로그에
  계속 뜨던 "uses or overrides a deprecated API"가 이것이었다. 새 타입은 제네릭
  <SELF>가 없어 선언도 짧아진다.

resource
  GenericContainer가 AutoCloseable이라 IDE가 try-with-resources를 권하는데
  여기서는 따르면 안 된다. 컨테이너는 JVM 전체가 공유하는 싱글턴이라 첫 테스트
  클래스가 끝날 때 닫히면 나머지가 죽은 포트를 본다 - 클래스 주석에 이미 적혀 있는
  함정이다. @SuppressWarnings("resource")에 그 이유를 달아 다음 사람이 "경고가
  뜨니 고쳐야지"로 되돌리지 않게 했다.

전수 확인은 -Xlint:all을 한시적으로 켜서 했고 정리 후 0건이다. 빌드에는 남기지
않았다 - 다른 파트 코드까지 영향을 주는 정책이라 별도 합의가 필요하다.

Redis는 그대로 GenericContainer다. Testcontainers 2.0.5 BOM에 Redis 전용 모듈이
없고, @Serviceconnection이 이미지 이름으로 붙으므로 이게 정상 경로다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
실제 Google 계정으로 로그인 흐름을 수동 검증하다 잡았다.

logged_in을 Path=/api/core로 발급하고 있었다. 쿠키는 현재 페이지 경로가 Path 아래일
때만 document.cookie에 나타나는데, 프론트 페이지는 /·/auth/callback·/feed처럼 루트
아래에서 서비스되고 /api/core 아래에서 실행되는 일이 없다. 즉 어떤 프론트 페이지에서도
읽을 수 없었다. 이 쿠키의 존재 이유가 "JS가 읽는 UI 힌트"(BD-21)인데 그 목적을
달성할 수 없는 상태였다.

*** HttpOnly가 아니라는 것은 읽을 수 있다는 뜻이 아니다 ***

테스트가 이것만 보고 있었다:

  assertThat(loggedIn).doesNotContain("HttpOnly");

HttpOnly는 "JS에게 숨기는가"를, Path는 "어느 페이지에서 보이는가"를 정한다. 둘 다
통과해야 읽히는데 앞의 하나만 확인했다. Access·Refresh는 서버가 읽으므로 헤더
문자열 검사로 충분했는데, logged_in만 읽는 주체가 브라우저 JS여서 같은 검증으로는
부족했다. 세 쿠키를 한 묶음으로 취급해 그 차이를 놓쳤다.

Path 단언을 회귀로 추가했고 고치기 전 expected "/" but was "/api/core"로 실패하는
것을 먼저 확인했다. 세 쿠키의 Path 기준을 "누가 읽어야 하는가"로 정리해 AuthCookies
javadoc과 인증 계약 문서에 남겼다.

  access_token   /api/core            서버 - 모든 API 요청
  refresh_token  /api/core/v1/auth    서버 - 재발급·로그아웃만
  logged_in      /                    프론트 JS

수동 검증으로 함께 확인된 것

- OIDC 경로가 실제로 동작한다. 테스트는 scope에서 openid를 빼고 돌아(서명된
  id_token·JWKS 검증 우회) OidcUserService 경로에 자동 커버리지가 0건이었다.
  운영 설정은 openid,email이다. sub·email이 정상 추출돼 저장되는 것을 확인했다
- redirect_uri 정확 일치, PKCE S256, nonce 모두 실제 Google에서 통과
- Secure 쿠키가 http://localhost에서 저장·전송된다. 코드 주석에 주장만 해두고
  검증한 적 없던 지점이다

이 흐름은 동의 화면이 사람의 브라우저를 요구해 CI에 넣을 수 없다. 인증 흐름을 바꿀 때
손으로 도는 절차로 authentication.md 8장에 남겼다.

검증: ./gradlew clean check --no-daemon 통과 (131개, 실패 0) + 실제 Google 로그인으로
document.cookie에 logged_in=1이 보이는 것 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
도메인 API 4개(S15P11A705-67~71)와 인증 스텁이 dev에 병합되어, 인증 계약 문서에
경고로 남겨둔 병합 지점이 실제로 발생했다. 텍스트 충돌 7건 외에 의미 충돌 5건이 있었다.

*** 마이그레이션 번호 충돌 ***

dev의 V3__core_domain.sql과 내 V3__social_account.sql이 같은 번호였다. Flyway가
"Found more than one migration with version 3"로 기동을 거부한다. 내 것을 V4로 밀었다.
테이블 이름은 겹치지 않는다(core_domain은 place·record·context·collection·
collection_record·follow, 내 것은 social_account).

로컬 DB에 V3=social_account가 이미 적용돼 있으면 checksum 검증에 걸린다. 로컬은
초기화가 필요하다.

*** BD·BI 번호 충돌 ***

dev가 BD-28·BI-16까지 썼다. decisions/README의 "번호는 dev에 머지된 기록을 기준으로
다음 값을 쓴다. 미머지 브랜치가 파일명으로 선점한 번호는 예약이 아니다" 규칙대로 밀었다.

  BD-27 NullMarked          -> BD-29
  BD-28 인가요청 쿠키       -> BD-30
  BD-29 RS256 키 관리       -> BD-31
  BI-08 쿠키 세션 JWT       -> BI-17

Java 주석·문서 링크의 참조까지 따라 고쳤다. dev 소유 참조(BD-27 커버리지 게이트,
BD-28 readiness)는 건드리지 않았다 — 같은 번호라도 가리키는 결정이 다르다.

*** 스텁 중복 ***

dev의 global/security/{LoginMember,MemberPrincipal,LoginMemberArgumentResolver}와
global/config/WebMvcConfig를 제거했다. 내 실제 구현이 security/authentication에 있고
같은 이름이 두 패키지에 공존하면 어느 리졸버가 등록되는지가 우연에 달린다.

도메인 컨트롤러 3개의 임포트를 security.authentication으로 돌렸다. X-Debug-Member-Id
분기와 pinlog.auth.stub.enabled 프로퍼티(application.yml·application-local.yml)도
제거했다 — 남기면 운영 인증 우회 구멍이다.

*** AuthTestSupport 한 곳만 바꿔 dev 테스트 전부가 통과했다 ***

loginAs 본문을 spring-security-test의 authentication() + csrf()로 바꿨다. csrf()를
함께 넣는 이유는 인증 도입으로 상태 변경 요청에 X-XSRF-TOKEN이 필수가 됐기 때문이고,
이게 없으면 post·patch·delete 테스트가 전부 403이 된다.

이 헬퍼를 쓰는 도메인 테스트 7개 파일·110여 개 호출부는 한 줄도 고치지 않았다.
스텁을 만든 쪽이 "인증 PR은 이 메서드 본문만 바꾼다"고 계약해 둔 판단이 값을 했다.

*** YAML 중복 키 ***

병합으로 pinlog: 루트 키가 두 개 생겼다. SnakeYAML이 거부하는 상태였다. 하나로 합쳤다.

*** 테스트 기반 클래스 이름 ***

내가 PostgresContainerSupport와 PostgresRedisContainerSupport를 하나로 합쳐
IntegrationContainerSupport로 바꿔 둔 것을, dev의 새 테스트 13개가 구 이름으로
참조했다. 이름을 맞추고 FlywayOutOfOrderTests·FlywayMigrationTests는 dev 버전을
취했다 — dev가 백엔드 테이블을 하드코딩 목록 대신 동적으로 발견하도록 일반화해 뒀고
그게 내 하드코딩보다 낫다. V4와 social_account만 기대값에 더했다.

*** 함께 채운 것 ***

dev README의 누락 행을 채웠다 — decisions/README에 BD-28 행이, implements/README에
BI-08~13 여섯 행이 파일은 있는데 목록에 없었다. 이 표를 고치는 중이었고, 등재 누락은
방치하면 인덱스 전체를 믿을 수 없게 만든다.

완료 조건 "404(타인 자원 접근)"가 이 병합으로 채워졌다. 도메인 리소스가 없어 검증
대상이 없다고 남겨 뒀는데, PublicCollectionApiTests의
withdrawnOwnersCollectionIsHiddenFromOthers·unpublishedCollectionIsHiddenFromOthers가
실제 인증 위에서 404를 고정한다.

검증: ./gradlew clean check --no-daemon 통과 (223개, 실패 0)
      내 131개 + dev 도메인 92개가 인증 도입 후에도 모두 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR 생성 이후 dev에 cc7753c(S15P11A705-116)가 들어와 BI-17을 선점했다.
내 기록을 BI-18로 한 번 더 밀었다.

  BI-17 쿠키 세션 JWT  ->  BI-18
  BI-17               =   dev의 별칭 키 생략 계약(S15P11A705-116)

BT-04와 WORKLOG의 참조를 따라 고쳤고, 어제 병합을 서술한 WORKLOG 줄의
"BI-08 -> BI-17로 재배정"도 최종 번호와 경위로 정정했다. 그대로 두면 그 링크가
dev의 별칭 기록을 가리켜 잘못된 곳으로 안내한다.

WORKLOG는 이번에 union으로 자동 병합됐다. 어제는 merge=union 설정 자체가 그 병합으로
처음 들어오는 중이라 적용되지 않았는데(git은 병합 시작 시점의 .gitattributes를 읽는다),
이제 브랜치에 있으므로 예고된 대로 동작한다. 중복 줄 없음을 확인했다.

검증: ./gradlew clean check --no-daemon 통과 (224개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cherry-go-round cherry-go-round self-assigned this Jul 28, 2026
@minyongP

Copy link
Copy Markdown
Contributor

리뷰

OAuthLoginSuccessHandlerTODO 한 줄이었던 자리에 토큰 파이프라인 전체가 들어왔습니다. 전반적으로 완성도가 높고, 특히 JWT PR에서 흔히 빠지는 것들이 다 들어 있습니다.

  • alg confusion 회귀 테스트 — 공개키를 HMAC 비밀로 삼은 HS256 위조 토큰 거부. 검증 알고리즘을 헤더의 alg가 아니라 JWSVerificationKeySelector에 RS256으로 고정한 것도 정확합니다.
  • delete 반환값으로 소비 판정 — 조회 후 삭제로 나눴을 때의 경쟁 조건을 처음부터 피했습니다.
  • token_use 분리를 HTTP 경계에서까지 고정AuthTokenContractTests.accessTokenIsNotAcceptedAsRefreshToken.
  • Filter 빈의 서블릿 체인 이중 등록을 짚고 @Component를 뺀 판단.
  • 60초 시계 오차 관용의 존재 자체를 테스트로 고정 — 발견하고 넘어간 게 아니라 계약으로 못 박았습니다.
  • AuthTestSupport.loginAs 본문만 바꿔 호출부 110여 개를 그대로 둔 것. back#28 계약이 실제로 값을 했습니다.

아래는 병합 전에 손봐야 한다고 보는 것들입니다.


🔴 반드시 고쳐야 함

1. XSRF-TOKEN 쿠키가 Path=/api/core로 나갑니다 — BT-04와 같은 버그입니다

CsrfConfigurer.spa()CookieCsrfTokenRepository.withHttpOnlyFalse()cookiePath 설정 없이 씁니다. Security 7.0.x의 saveToken은 이렇게 동작합니다.

.path(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request))
// getRequestContext → contextPath.length() > 0 ? contextPath : "/"

server.servlet.context-path: /api/core이므로 XSRF-TOKENPath=/api/core로 저장됩니다. 프론트는 /·/auth/callback 아래에서 도는데(client-redirect-uri: /auth/callback, "프론트와 같은 오리진"), 그 페이지의 document.cookie에는 Path=/api/core 쿠키가 나타나지 않습니다. 즉 클라이언트는 X-XSRF-TOKEN에 넣을 값을 읽을 수 없고, 모든 상태 변경 요청이 영영 403이 됩니다.

CsrfCookieFilter가 해결한 문제(쿠키가 안 나가는 것)와는 다른 층위입니다. 쿠키는 나가지만 프론트가 읽지 못합니다. BT-04에서 logged_in을 두고 정확히 같은 판단을 내렸는데 XSRF-TOKEN에는 적용되지 않았습니다.

SecurityConfig에서:

CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
csrfTokenRepository.setCookiePath("/");   // 프론트가 읽어야 한다. BT-04와 같은 이유
...
.csrf(csrf -> csrf
    .csrfTokenRepository(csrfTokenRepository)
    .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))

테스트가 이걸 못 잡는 이유도 같이 봐야 합니다. AuthTokenContractTests.postWithCsrfSet-Cookie 헤더 원문에서 값을 꺼내므로 브라우저의 Path 범위 제한을 우회합니다. logged_in에 붙인 것과 같은 단언을 추가하면 고정됩니다.

assertThat(attribute(setCookie(response, CSRF_COOKIE), "Path"))
    .as("프론트가 document.cookie로 읽어야 한다. API 경로로 좁히면 헤더에 넣을 값을 구할 수 없다")
    .isEqualTo("/");

2. OAuthLoginSuccessHandler에 오류 경로가 없습니다

onAuthenticationSuccess는 필터 체인 안에서 돌아 @RestControllerAdvice를 타지 않습니다(SecurityErrorWriter 주석이 이미 이 사실을 알고 있죠). 그런데 이 안에서 세 갈래로 예외가 나갈 수 있습니다.

  • OAuthUserInfo.fromUnsupportedSocialProviderException(KAKAO·NAVER), IllegalStateException(sub 없음)
  • SocialLoginService.signUpDataIntegrityViolationException. 같은 소셜 계정의 첫 로그인이 동시에 두 번 들어오면 ux_social_account_provider_user 부분 유니크에 걸립니다. 확률은 낮지만 더블클릭 정도로도 재현 가능합니다.

셋 다 지금은 톰캣 기본 500 페이지로 나갑니다 — 공통 envelope도 아니고, 명세가 정한 /auth/callback?error=OAUTH_FAILED 복귀도 아닙니다. 성공 핸들러 본문을 감싸 실패 핸들러와 같은 경로로 보내는 게 맞습니다. signUp 쪽은 DataIntegrityViolationException을 잡아 재조회로 흡수하면 사용자 눈에는 정상 로그인이 됩니다.


🟡 논의가 필요함

3. Refresh 재사용 감지가 세션 계열을 끊지 않습니다

rotate는 소비된 jti에 401만 돌려줍니다. 토큰이 유출된 상황을 보면, 공격자가 먼저 회전하면 정상 사용자가 401을 받고 공격자의 새 토큰은 살아 있습니다. RFC 9700 §4.14.2가 재사용 감지 시 해당 사용자의 refresh 토큰 전부를 폐기하라고 하는 게 이 시나리오 때문입니다.

jti당 키 하나 구조라 전부 지우려면 auth:refresh:<memberId>:* SCAN이 필요한데, 회원당 Set을 하나 더 두고 jti를 담으면 O(1)로 끊을 수 있습니다. 지금 안 하겠다면 최소한 재사용 감지를 WARN으로 남기고, BD-21 감수 항목에 "재사용 감지 시 계열 폐기는 하지 않는다"를 명시해 두는 편이 낫습니다. 지금은 로그아웃 한계만 적혀 있어 이 구멍이 문서에 안 보입니다.

4. ObjectInputFilter에 자원 한도가 없습니다

"org.springframework.security.oauth2.core.**;java.util.**;java.lang.**;!*"

클래스 기반 gadget 체인은 잘 막았습니다. 다만 java.util.**이 열려 있으면 중첩 HashSet/HashMap 해시 증폭(SerialDOS)이 그대로 통과합니다 — readObjecthashCode()를 재귀 호출해서, 수 KB짜리 쿠키 하나로 CPU를 수 분간 태울 수 있습니다. 이 경로는 인증 이전에 돌고 입력은 전적으로 클라이언트가 줍니다.

한도만 앞에 붙이면 닫힙니다.

ObjectInputFilter.Config.createFilter(
    "maxdepth=20;maxrefs=1000;maxbytes=8192;maxarray=1000;"
        + "org.springframework.security.oauth2.core.**;java.util.**;java.lang.**;!*");

부수적으로 이 쿠키에는 무결성 보호(MAC)가 없습니다. HttpOnly라 JS로는 못 건드리지만 같은 오리진에 쿠키를 심을 수 있는 상황이면 인가 요청을 갈아끼울 수 있습니다. 한도 추가보다 우선순위는 낮으니 후속으로 봐도 됩니다.

5. 탈퇴한 회원의 세션이 살아 있습니다

JwtAuthenticationFilterrotate도 회원이 아직 활성인지 보지 않습니다. 무상태 설계상 Access 30분은 감수 항목이 맞지만, rotate는 회원 확인 없이 계속 새 토큰을 내주므로 탈퇴한 회원이 7일마다 세션을 무한히 갱신할 수 있습니다. 탈퇴 티켓(S15P11A705-65)의 완료 조건에 "탈퇴 시 auth:refresh:<memberId>:* 폐기"를 넣어 두면 여기서 새지 않습니다. 이 PR에서 고칠 일은 아니지만 티켓 간 의존으로 남겨야 합니다.


🟢 사소한 것

  • SocialAccount.create(..., String email)SocialLoginService(@NullMarked)가 @NullableuserInfo.email()을 그대로 넘깁니다. domain/member/entity가 아직 @NullMarked가 아니라 지금은 컴파일되지만, BD-29가 말하는 확대 방향대로 가면 깨집니다. 지금 @Nullable String email로 표시해 두는 게 쌉니다.
  • logged_in 수명이 7일인데 Access는 30분입니다. 30분 방치한 탭은 재발급이 돌기 전까지 로그인 상태로 표시됩니다. 의도한 것이면 AuthCookies 주석에 한 줄 남겨 주세요.
  • IntegrationContainerSupport의 static 블록 주석만 영어입니다. 나머지 파일 전체가 한국어라 눈에 띕니다.

리뷰 포인트에 대한 답

  1. merge vs rebase — 21개 커밋 × 의미 충돌 5건이면 rebase 비용이 실제로 더 큽니다. c011cff 선례도 있고 병합 전 태그도 잡아 뒀으니 이대로 갑니다.
  2. V3 → V4 — 맞는 판단입니다. docker compose down -v 안내가 본문에 있으니 충분합니다.
  3. loginAs 본문만 교체 — 이 PR에서 값이 가장 잘 드러난 지점입니다. csrf()를 함께 넣은 이유까지 주석에 남긴 것도 좋습니다.
  4. 로그아웃 한계 — 문서화된 감수 항목이라 받아들입니다. 다만 위 3번은 같은 문서에 아직 안 적혀 있습니다.
  5. PR 크기 — 인증 계약상 쪼갤 수 없는 게 맞습니다. 커밋 유형별 분리가 도움이 됐습니다.

1번(CSRF 쿠키 Path)과 2번(성공 핸들러 오류 경로)을 고치면 병합해도 된다고 봅니다. 1번은 프론트를 붙이는 순간 전면 403으로 드러날 것이라 배포 전에 반드시 잡아야 하고, 고치는 비용도 두 줄입니다. 3·4번은 코드로 처리하든 BD-21/BD-31 감수 항목에 명시하든 둘 중 하나는 필요합니다. 5번 이하는 후속으로 충분합니다.

@minyongP minyongP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

총평

병합에 반대하지 않습니다. 보안 핵심을 정확히 짚었습니다 — 검증 알고리즘 RS256 고정(RFC 8725, alg confusion 차단 테스트까지 있음), PKCE 명시 활성화, Refresh 회전을 delete 반환값 하나로 원자적으로 판정, 운영 키 미주입 시 fail-fast. 테스트가 HTTP 경계 계약으로 작성되어 있어 내부 리팩터링에 흔들리지 않는 점도 좋습니다. 아래는 정리 항목과 동작 관련 검토 요청입니다.

반드시 정리 (이 PR에서)

1. 죽은 프로퍼티 pinlog.auth.stub.enabled=true가 테스트 6곳에 남아 있습니다.

  • CollectionApiTests, PublicCollectionApiTests, FollowApiTests, DeletionApiTests, RecordApiTests, RecordMapApiTests

스텁이 이 PR에서 제거되어 이 프로퍼티는 어디에도 바인딩되지 않습니다. 남겨두면 "스텁이 아직 있다"는 거짓 신호가 되고, 스텁 계약(back#28)이 요구한 "프로퍼티를 반드시 제거한다"의 마지막 조각이기도 합니다. 같은 파일의 management.health.redis.enabled=falseIntegrationContainerSupport가 Redis를 항상 띄우는 지금은 이유가 사라졌으니 함께 정리하면 좋겠습니다.

검토 요청 (동작)

2. 신규 가입 동시성 — 콜백 경합이 사용자에게 500으로 나갑니다.

SocialLoginService.login()은 조회 → 없으면 가입인데, 같은 신규 사용자의 콜백 두 개가 동시에 들어오면 둘 다 조회를 비켜가고 한쪽이 부분 유니크 인덱스(ux_social_account_provider_user) 위반으로 터집니다. 이 예외는 성공 핸들러 밖으로 나가므로 실패 핸들러(OAUTH_FAILED 리다이렉트)를 타지 않고 500이 됩니다. #69에서 중복 팔로우 경합을 409로 바꾼 것과 같은 종류의 문제입니다. DataIntegrityViolationException을 잡아 재조회로 로그인 처리하면 사용자는 그냥 로그인됩니다.

3. 인가 요청 쿠키 수명 180초가 실사용에 빠듯할 수 있습니다.

Google 화면에서 계정 선택 + 비밀번호 재입력 + 2단계 인증을 거치면 3분을 넘기는 경우가 실제로 있습니다. 넘기면 콜백에서 인가 요청을 못 찾아 OAUTH_FAILED로 돌아오고, 사용자는 이유를 모른 채 재로그인하게 됩니다. 300~600초를 고려해 주세요.

4. 인가 요청 쿠키의 Java 직렬화는 Spring Security 버전 업그레이드 순간 왕복 중 로그인을 깨뜨립니다.

역직렬화 필터를 건 것은 좋습니다. 다만 OAuth2AuthorizationRequest의 Java 직렬화 형식은 버전 간 호환이 보장되지 않아, 라이브러리를 올려 배포하는 순간 진행 중이던 로그인 왕복은 전부 실패합니다(재로그인 유도로 끝나므로 치명적이진 않음). 필요한 필드만 뽑아 JSON으로 담으면 이 결합이 없어집니다 — 후속으로 충분합니다.

5. Redis가 로그인·재발급의 경성 의존이 됐습니다.

Redis 순단 시 로그인 콜백과 /v1/auth/refresh가 500이 됩니다. BD-28대로 readiness에는 Redis가 없으므로 파드는 정상인데 로그인만 전부 실패하는 상태가 됩니다. 이미 발급된 Access(최대 30분)는 계속 동작하므로 감수할 만하지만, 의도한 감수라는 것을 BD-21이나 BI-18에 한 줄 남겨두면 장애 때 판단이 빨라집니다.

병합·배포 전 확인 (PR 본문 재확인)

  • 인프라 선행: JWT_PRIVATE_KEY 미주입 시 운영 파드 기동 실패(의도된 fail-fast). GOOGLE_CLIENT_ID/SECRET 미주입 시 기동은 되지만 로그인 불가(client_id=unset으로 Google에서 거절). 다음 운영 배포 전에 Secret 주입이 선행돼야 합니다.
  • 프론트 선행: 모든 상태 변경 요청에 X-XSRF-TOKEN 헤더 필수(없으면 403), 미인증 응답 404 → 401 변경, X-Debug-Member-Id 폐지. 공유 dev 환경에 배포되기 전에 프론트가 준비돼야 합니다.

사소

  • AuthenticatedTestController는 javadoc대로 실제 me API가 병합되면 제거 대상입니다. 해당 도메인 티켓에 메모를 남겨두면 잊지 않습니다.

리뷰 포인트에 대한 답

  • merge vs rebase(포인트 1): merge 판단에 동의합니다. 의미 충돌 5건을 21개 커밋에 걸쳐 반복 해소하는 비용·위험이 더 크고, 저장소에 선례(c011cff)도 있습니다.
  • V3 → V4(포인트 2): 번호 정리는 맞습니다. 이 브랜치의 옛 V3(social_account)를 로컬에 적용했던 사람만 docker compose down -v가 필요하고, 공유 환경엔 영향이 없다는 정리도 정확합니다.

*** XSRF-TOKEN 쿠키가 Path=/api/core로 나갔다 (BT-04와 같은 종류) ***

CsrfConfigurer.spa()의 기본 저장소는 cookiePath가 비어 있으면 요청의 context path를
쓴다. 우리는 /api/core라서 쿠키가 그 경로로 저장되는데 프론트는 /·/auth/callback
아래에서 돈다. document.cookie에 나타나지 않으니 X-XSRF-TOKEN에 넣을 값을 구할 수
없고, 상태 변경 요청이 전부 403이 된다. 프론트를 붙이는 순간 전면 장애로 드러날
버그였다.

logged_in에서 똑같은 판단을 내려 놓고(BT-04) CSRF 쿠키에는 적용하지 않았다.
실행 중인 앱에 curl로 Path=/api/core를 실증한 뒤 저장소를 직접 주입해 / 로 고쳤다.
spa()의 요청 핸들러는 CsrfConfigurer 내부 클래스라 지정할 수 없어, spa()를 부른 뒤
저장소만 덮어쓰는 순서를 쓴다.

테스트가 못 잡은 이유도 함께 고쳤다. postWithCsrf가 Set-Cookie 원문에서 값을 꺼내
쓰므로 브라우저의 Path 제한을 우회하고 있었다 — 정상 경로 테스트가 통과해도 실제
브라우저에서는 깨질 수 있었다. logged_in에 붙인 것과 같은 Path 단언을 추가했다.

*** 성공 핸들러에 오류 경로가 없었다 ***

onAuthenticationSuccess는 필터 체인 안에서 돌아 @RestControllerAdvice를 타지 않는다.
provider 미지원, 공급자 응답에 sub 없음, 토큰 발급 실패(Redis 장애 포함)가 모두
컨테이너 기본 오류 페이지로 나갔다 — 공통 envelope도, 명세가 정한
/auth/callback?error=OAUTH_FAILED 복귀도 아니었다. 본문을 감싸 실패 핸들러로 보낸다.

신규 가입 동시성도 여기서 흡수한다. 같은 소셜 계정의 첫 로그인이 동시에 들어오면
ux_social_account_provider_user를 위반하는데, 성공 핸들러는 트랜잭션 밖이라 재호출이
프록시를 타고 새 트랜잭션을 연다. 한 번만 재시도하면 이긴 쪽이 커밋한 계정을 찾는다.
진 쪽은 트랜잭션이 통째로 롤백되므로 고아 회원도 남지 않는다. BI-14가 예외를 잡지
않고 ON CONFLICT로 간 이유(같은 트랜잭션은 rollback-only)는 여기에 해당하지 않는다.

테스트 트리거는 email VARCHAR(255) 상한을 넘기는 값이다. 빈 sub로 시도했더니
Spring 내부(OAuth2AuthorizedClient 생성)에서 IllegalArgumentException으로 먼저 죽어
우리 경로에 도달하지 않았다 — 그 한계는 BI-18에 적었다.

정리

- 죽은 pinlog.auth.stub.enabled=true 6곳 제거. 스텁은 이 PR에서 없앴으므로 어디에도
  바인딩되지 않는다. back#28 계약이 요구한 "프로퍼티를 반드시 제거한다"의 마지막 조각
- 무의미해진 management.health.redis.enabled=false 12곳 제거. IntegrationContainerSupport가
  Redis를 항상 띄운다. ReadinessProbe* 둘은 의도적으로 설정한 것이라 유지
- 역직렬화 필터에 자원 한도(maxdepth·maxrefs·maxbytes·maxarray) 추가. 클래스 허용목록만
  으로는 중첩 HashSet 해시 증폭(SerialDOS)이 통과한다. 인증 이전에 도는 경로이고 입력은
  전적으로 클라이언트가 준다
- 인가 요청 쿠키 수명 180초 -> 600초. 계정 선택 + 비밀번호 + 2단계 인증이면 3분을 넘긴다
- BD-32 신설 — Refresh 재사용을 감지해도 세션 계열을 폐기하지 않는다는 사실을 명시하고
  감지 WARN을 추가했다. RFC 9700 4.14.2가 권고하는 것을 지금 안 하는 이유는 위험이 작아서가
  아니라 PR 크기 때문이며, 탈퇴 티켓(S15P11A705-65)과 같은 자료구조가 필요하므로 함께 하는
  것이 자연스럽다고 적었다
- BI-18에 감수 항목 기록 — Redis가 로그인·재발급의 경성 의존이 된 것(파드는 Ready인데
  로그인만 실패하는 증상), Java 직렬화의 버전 결합, 규격 밖 공급자 응답의 한계
- SocialAccount.create의 email에 @nullable 표기, logged_in 수명이 Refresh를 따르는 이유
  주석, IntegrationContainerSupport의 영어 주석 한국어화

검증: ./gradlew clean check --no-daemon 통과 (226개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

리뷰 두 건 모두 반영했습니다 — 628d674. ./gradlew clean check --no-daemon 226개 통과, 실패 0.

🔴 고쳤습니다

1. XSRF-TOKEN 쿠키 Path — 지적이 정확했고, 실증했습니다

실행 중인 앱에 curl로 확인한 실제 헤더입니다.

Set-Cookie: XSRF-TOKEN=4d9c660b-4e0c-4584-8c2d-070db14ba251; Path=/api/core

BT-04에서 logged_in에 내린 판단을 CSRF 쿠키에는 적용하지 않았습니다. 프론트를 붙이는 순간 전면 403이 될 버그였습니다.

spa()의 요청 핸들러가 CsrfConfigurer 내부 클래스라 직접 지정할 수 없어서, 제안해 주신 형태 대신 spa()를 부른 뒤 저장소만 덮어쓰는 순서로 갔습니다.

.csrf(csrf -> csrf
    .spa()
    .csrfTokenRepository(csrfTokenRepository))   // Path=/ 로 설정된 것

"테스트가 이걸 못 잡는 이유도 같이 봐야 합니다" — 그 지적이 이번 리뷰에서 가장 값이 컸습니다. postWithCsrfSet-Cookie 원문에서 값을 꺼내 브라우저의 Path 제한을 우회하고 있었습니다. 제안하신 단언을 그대로 추가했고, 고치기 전 expected "/" but was "/api/core"로 실패하는 것을 먼저 확인했습니다.

2. 성공 핸들러 오류 경로

본문을 감싸 실패 핸들러로 보냅니다. 두 리뷰가 같이 짚은 신규 가입 동시성도 여기서 흡수합니다.

ON CONFLICT(BI-14)가 아니라 재시도를 택한 이유를 적어둡니다 — 성공 핸들러는 @Transactional 이라 재호출이 프록시를 타고 새 트랜잭션을 엽니다. BI-14가 예외를 안 잡은 이유(같은 트랜잭션은 rollback-only)가 여기엔 해당하지 않습니다. 진 쪽은 트랜잭션이 통째로 롤백되므로 고아 회원도 남지 않습니다.

테스트를 짜다 하나 알게 됐습니다.sub로 트리거하려 했더니 Spring 내부(OAuth2AuthorizedClient 생성)에서 IllegalArgumentException으로 먼저 죽어 우리 핸들러에 도달하지 않습니다. AbstractAuthenticationProcessingFilterAuthenticationException만 실패 핸들러로 넘기므로 이 경로는 감쌀 자리가 없습니다. 한계로 BI-18에 기록했고, 테스트 트리거는 email VARCHAR(255) 초과로 바꿨습니다.

🟡 코드로 처리

항목 처리
역직렬화 자원 한도 maxdepth=20;maxrefs=1000;maxbytes=8192;maxarray=1000 추가. 인증 이전에 도는 경로이고 입력이 전적으로 클라이언트 것이라 지적대로 위험이 실재합니다
인가 요청 쿠키 수명 180초 → 600초. 2단계 인증까지 거치면 3분이 빠듯하다는 지적에 동의합니다
죽은 pinlog.auth.stub.enabled 6곳 제거. back#28 계약의 마지막 조각이었습니다
management.health.redis.enabled=false 12곳 제거. ReadinessProbe* 둘은 유지했습니다 — 하나는 =true로 Redis 장애를 재현하고, 하나는 DB 장애를 격리하려고 끈 것이라 의도적입니다
@Nullable email, logged_in 수명 주석, 영어 주석 반영

🟡 문서로 명시 (BD-32 신설)

Refresh 재사용 시 계열 폐기를 하지 않는다는 사실을 BD-32로 남기고 감지 WARN을 추가했습니다. 지적하신 유출 시나리오(공격자가 먼저 회전 → 정상 사용자만 끊김)를 그대로 적었습니다.

"위험이 작아서 미루는 것이 아니라 PR 크기 때문" 이라고 명시했고, 재검토 트리거로 탈퇴 티켓(S15P11A705-65)과 같은 자료구조(회원당 Set)가 필요하니 함께 하는 것이 자연스럽다를 넣었습니다.

BI-18에는 Redis가 로그인·재발급의 경성 의존이 된 것(BD-28대로 readiness에 Redis가 없어 파드는 Ready인데 로그인만 실패하는 증상)과 Java 직렬화의 버전 결합을 감수 항목으로 적었습니다. 장애 때 원인 찾는 시간을 줄이자는 취지 그대로입니다.

후속으로 남깁니다

  • 인가 요청 쿠키를 JSON으로 전환 (Java 직렬화 결합 제거)
  • 탈퇴 시 auth:refresh:<memberId>:* 폐기를 S15P11A705-65 완료 조건에 추가 — 지적대로 티켓 간 의존으로 남깁니다
  • AuthenticatedTestController 제거를 me API 티켓에 메모

배포 선행 조건 (재확인 감사합니다)

  • 인프라: JWT_PRIVATE_KEY 미주입 시 운영 파드 기동 실패(의도된 fail-fast). GOOGLE_CLIENT_ID/SECRET은 기동은 되나 로그인 불가
  • 프론트: 상태 변경 요청에 X-XSRF-TOKEN 필수, 미인증 404 → 401, X-Debug-Member-Id 폐지. 위 1번을 고치기 전이었다면 프론트가 토큰을 읽을 수조차 없었습니다

@minyongP minyongP left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

628d674 확인했습니다. 지적한 항목은 전부 처리됐고(죽은 프로퍼티 6곳 grep 0건, 동시성 흡수, 쿠키 수명 600초, BI-18 감수 기록), 제가 놓친 CSRF 쿠키 Path 버그를 찾아낸 것과 테스트가 왜 그걸 못 잡았는지(postWithCsrfSet-Cookie 원문을 읽어 브라우저 Path 제한을 우회)까지 같이 고친 것이 이번 커밋에서 가장 값있는 부분이라고 봅니다. 통과하는 테스트가 거짓 보증이던 자리를 찾은 것이니까요.

병합에 반대하지 않습니다. 아래 두 개는 후속으로 충분한 항목이고, 기록으로 남깁니다.

1. SecurityContractTests가 존재하지 않는 경로를 검증합니다

SecurityContractTests.java:54·60/api/core/v1/me/summary를 때리는데 이 경로는 아직 구현되지 않았습니다. /me/summary는 명세 3.5에 있지만 컨트롤러가 없습니다.

@DisplayName("보호 경로에 인증 없이 접근하면 401을 반환한다")
void protectedPathReturnsUnauthorized() throws Exception {
    assertThat(get("/api/core/v1/me/summary").statusCode()).isEqualTo(401);
}

그래서 이 테스트가 실제로 검증하는 것은 "매핑되지 않은 경로도 401"이고, DeploymentContractTests.unmappedServiceUrlRequiresAuthentication과 같은 내용입니다. 실제 도메인 엔드포인트가 실수로 permitAll에 들어가도 이 테스트는 그대로 통과합니다 — 이름이 약속하는 계약을 지키지 못합니다.

/api/core/v1/collections/api/core/v1/records/map처럼 매핑된 엔드포인트로 바꾸면 됩니다. 401 envelope를 보는 두 번째 테스트도 같은 경로를 쓰므로 함께 바꾸면 됩니다.

CSRF Path 버그와 같은 종류의 문제입니다 — 테스트가 통과하는데 검증 대상이 실제와 다른 자리요.

2. CSRF 쿠키의 Secure만 환경 의존입니다

SecurityConfig.java:88CookieCsrfTokenRepositorysecure를 지정하지 않았습니다. 이 경우 Spring은 request.isSecure()를 따르므로, 운영에서 Secure가 붙는 것이 Traefik의 X-Forwarded-Proto가 정확히 오는 것에 의존하게 됩니다.

AuthCookies는 같은 판단을 다르게 내렸습니다.

return ResponseCookie.from(name, value).secure(true)...

javadoc에 근거까지 적혀 있습니다 — "브라우저는 http://localhost를 신뢰할 수 있는 오리진으로 취급해 Secure 쿠키를 그대로 보내므로, 로컬 전용 예외를 두면 운영과 다른 경로를 검증하게 될 뿐이다." 그 논리가 CSRF 쿠키에도 그대로 적용됩니다.

새로 추가한 csrfCookieIsReadableFromClientPagesPathHttpOnly만 단언하고 Secure는 보지 않아서, 이 속성은 지금 테스트로 고정되지 않은 상태입니다.

CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookiePath("/");
repository.setSecure(true);   // AuthCookies와 같은 이유

setSecure(true) 한 줄과 단언 하나로 forward-headers-strategy 설정에 대한 의존이 사라집니다. 보안 속성이 인프라 설정 하나에 걸려 있지 않은 편이 낫습니다.


둘 다 병합 후 별도 커밋으로 처리해도 무방합니다. 배포 조건(인프라 JWT_PRIVATE_KEY·Google Secret 선행, 프론트 X-XSRF-TOKEN 준비)은 앞선 리뷰와 같습니다.

@colosair
colosair removed their request for review July 29, 2026 01:33
*** SecurityContractTests가 존재하지 않는 경로를 때리고 있었다 ***

/v1/me/summary는 main에 구현되어 있지 않다. 그래서 이 테스트가 실제로 검증한 것은
"매핑되지 않은 URL도 401"이었고, DeploymentContractTests.unmappedServiceUrlRequiresAuthentication
과 같은 내용이었다. 매핑된 /v1/collections로 바꿨다.

다만 리뷰의 근거는 절반만 맞았다. "실제 엔드포인트가 실수로 permitAll에 들어가도 이
테스트는 통과한다"를 실험으로 확인해 봤는데, 경로를 바꾼 뒤에도 통과한다.
LoginMemberArgumentResolver가 fail-closed라 인증이 없으면 거기서 401을 던지기 때문이다.

즉 permitAll 실수는 리졸버가 막아 준다(의도한 방어 두 겹). 두 층 중 어느 쪽이 막았는지는
응답만 봐서 구분되지 않는다. 이 테스트가 고정하는 것은 바깥에서 관측 가능한 계약 -
실재하는 보호 경로에 미인증으로 들어가면 401이 공통 envelope로 나온다 - 이며, 그
사실을 상수 javadoc에 적었다. 처음엔 나도 "permitAll을 잡는다"고 적었다가 위 실험으로
반증하고 고쳤다.

*** CSRF 쿠키의 Secure가 환경 의존이었다 ***

CookieCsrfTokenRepository는 secure를 지정하지 않으면 request.isSecure()를 따른다
(saveToken 소스로 확인). 그러면 이 보안 속성이 프록시가 보내는 X-Forwarded-Proto가
정확한지에 걸린다. AuthCookies는 같은 이유로 이미 secure(true)를 고정하고 있었고,
인증 쿠키와 CSRF 쿠키가 다른 규칙을 따를 이유가 없다.

리뷰가 제안한 repository.setSecure(true)는 Spring Security 7.1.0에 없는 메서드다.
공개 API는 setCookieName/setCookiePath/setParameterName/setHeaderName/setCookieCustomizer
뿐이라 setCookieCustomizer로 처리했다. 커스터마이저가 기본값 적용 뒤에 실행되는 것을
소스에서 확인하고 덮어쓴다.

SameSite도 빠져 있었다(실제 응답이 "XSRF-TOKEN=...; Path=/" 뿐이었다). AuthCookies가
셋 다 SameSite=Lax를 붙이므로 같이 넣었다.

RED: 고치기 전 Path=/는 적용됐고 Secure가 없어 실패하는 것을 먼저 확인했다.

검증: ./gradlew clean check --no-daemon 통과 (226개, 실패 0)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

두 건 모두 반영했습니다 — d462891. ./gradlew clean check --no-daemon 226개 통과, 실패 0.

두 항목 다 후속으로도 무방하다고 하셨지만, 1번은 "통과하는데 검증 대상이 다른" 테스트를 알면서 병합하는 셈이라 지금 처리했습니다. 직전 커밋에서 같은 종류를 잡아 놓고 새로 하나를 남겨두는 게 이상해서요.


1. 보호 경로 테스트 — 고쳤습니다. 다만 근거는 절반만 맞았습니다

/v1/me/summary가 구현되어 있지 않다는 것, 그래서 실제로 검증된 게 DeploymentContractTests와 같은 내용이었다는 것 — 맞습니다. /v1/collections로 바꿨습니다.

그런데 "실제 도메인 엔드포인트가 실수로 permitAll에 들어가도 이 테스트는 그대로 통과합니다" 를 그대로 받지 않고 실험했습니다. /v1/collections를 임시로 permitAll에 넣고 돌렸더니 — 경로를 바꾼 뒤에도 통과합니다.

원인은 LoginMemberArgumentResolver가 fail-closed라서입니다.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
    throw new UnauthorizedException();
}

인가 규칙을 뚫어도 리졸버가 401을 던집니다. 그 클래스 javadoc에 적어둔 의도가 그대로 작동한 겁니다 — "나중에 공개 경로가 늘었을 때 principal이 조용히 null로 들어가는 편보다 401로 끊기는 편이 안전하다."

그래서 permitAll 실수를 막는 건 이 테스트가 아니라 리졸버입니다. 방어가 두 겹이고, 응답만 봐서는 어느 쪽이 막았는지 구분되지 않습니다(둘 다 UNAUTHORIZED envelope).

이 테스트가 고정하는 것은 바깥에서 관측 가능한 계약 — 실재하는 보호 경로에 미인증으로 들어가면 401이 공통 envelope로 나온다 — 이고, 어느 층이 막았는지는 계약이 아닙니다. 그 내용을 상수 javadoc에 적었습니다.

참고로 저도 처음엔 지적하신 문장을 그대로 옮겨 "이제 permitAll을 잡는다" 고 javadoc에 썼다가, 위 실험으로 스스로 반증하고 고쳤습니다.

만약 "인가 규칙 자체"를 격리해서 검증하고 싶다면 별도 방법이 필요합니다. @LoginMember를 받지 않는 보호 엔드포인트가 없어서 지금은 리졸버를 우회할 방법이 없고, 그런 엔드포인트를 테스트용으로 만드는 건 과해 보입니다. 필요하다고 보시면 후속으로 논의하겠습니다.

2. CSRF 쿠키 Secure — 고쳤습니다. 제안하신 API는 없어서 다른 경로로 갔습니다

request.isSecure() 위임은 saveToken 소스로 확인했고, 실제 응답도 Secure가 없었습니다.

Set-Cookie: XSRF-TOKEN=012049d7-...; Path=/

AuthCookies가 같은 이유로 이미 secure(true)를 고정하고 있는데 CSRF 쿠키만 환경에 맡겨 둔 게 일관되지 않다는 지적, 그대로 동의합니다.

다만 repository.setSecure(true)는 Spring Security 7.1.0에 없습니다. CookieCsrfTokenRepository의 공개 API는 이것뿐입니다.

setCookieName / setCookiePath / setParameterName / setHeaderName / setCookieCustomizer

setCookieCustomizer가 유일한 경로이고, 소스상 기본값을 적용한 뒤에 실행되어 덮어쓸 수 있습니다(saveToken에서 .secure(...) 다음 줄이 cookieCustomizer.accept(cookieBuilder)).

CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
repository.setCookiePath("/");
repository.setCookieCustomizer(cookie -> cookie.secure(true).sameSite("Lax"));

SameSite도 함께 넣었습니다. 지적 항목은 아니었는데, 위 응답에서 보이듯 CSRF 쿠키에만 빠져 있었습니다. AuthCookies는 셋 다 SameSite=Lax를 붙이므로 같은 논리가 적용됩니다. 이제 네 쿠키가 모두 같은 규칙을 따릅니다.

RED는 Path=/는 적용됐고 Secure가 없는 상태로 먼저 확인했습니다.


배포 선행 조건(인프라 JWT_PRIVATE_KEY·Google Secret, 프론트 X-XSRF-TOKEN 준비)은 앞선 리뷰와 같고 변동 없습니다.

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.

feat(S15P11A705-63): Google 소셜 로그인·로그아웃과 JWT 쿠키 인증 도입

2 participants