Skip to content

Latest commit

 

History

History
242 lines (160 loc) · 21.9 KB

File metadata and controls

242 lines (160 loc) · 21.9 KB

인증 PR 계약

시작 절차와 규칙은 CONTRIBUTING.md를 따릅니다. 이 문서는 인증·인가 기능을 도입하는 PR이 반드시 만족해야 할 계약을 정의합니다.

공용 계약 원본은 Team-PinLog/docs11_인증_설계08_API_명세 §1입니다. 백엔드가 감수하는 것과 재검토 트리거는 BD-21·BD-22에 있습니다. 이 문서는 그 계약을 PR이 지켜야 할 형태로 옮긴 것이며, 공용 계약과 충돌하면 공용 계약이 이깁니다.

확정된 사실 (구현 전에 알아야 할 것)

항목
토큰 전달 HttpOnly+Secure+SameSite=Lax 쿠키. Bearer 헤더를 쓰지 않고, 응답 본문에 토큰을 담지 않습니다
기본 경로 /api/core/v1 — context path(/api/core)에 컨트롤러가 /v1을 직접 붙입니다(API 규약)
Refresh 쿠키 범위 Path=/api/core/v1/auth — 일반 API 요청에 실리지 않습니다
CSRF XSRF-TOKEN 쿠키 → X-XSRF-TOKEN 헤더. 상태를 바꾸는 요청에 필수
가입 확정 소셜 인증 성공이 곧 가입 완료. 약관은 로그인 시작 이전(클라이언트)이며 서버에 동의 API가 없습니다(BD-22)

상태 코드는 용도가 갈려 있습니다(08_API_명세 §1). 섞으면 클라이언트 분기가 깨집니다.

상태 이 코드만 쓰는 경우
401 인증 실패 — 쿠키 없음·만료, 회전 전 Refresh 재사용
403 CSRF 토큰 누락·불일치 전용
404 리소스 없음 또는 자원 접근 권한 실패(존재 여부를 노출하지 않음, BD-13)
503 인증 여부를 확인하지 못함 — 탈퇴 판정이 DB 오류로 실패. 자격증명 문제가 아니므로 401로 말하지 않습니다(BD-47)

배경 — 인증은 실수로 빠진 것이 아니었다

backend foundation reset은 Spring Security, OAuth, 임시 계정, SecurityConfig의도적으로 제거했습니다. 인증 없이도 서비스가 실행·테스트·배포되도록 기반을 먼저 정리하기 위함이었고, 그동안 core 도메인은 인증 스텁 위에서 개발됐습니다(back#28 합의, Jira 작업).

인증은 Jira 작업에서 한 PR로 병합됐습니다. 스텁은 그 PR에서 제거됐습니다 — 아래는 스텁이 고정해 둔 계약 중 그대로 이어받은 것입니다.

  • principal 타입은 MemberPrincipal(Long memberId) record이고, 컨트롤러는 @LoginMember MemberPrincipal로 받습니다. 이 시그니처는 바뀌지 않았습니다 — 도메인 컨트롤러가 수정 대상이 되지 않도록 스텁이 미리 고정해 둔 값이고, 그 판단이 실제로 값을 했습니다.
  • 서비스는 Long memberId 파라미터를 받습니다. SecurityContext를 서비스에서 직접 읽지 않습니다.
  • 테스트 인증 주입은 support/AuthTestSupport.loginAs(memberId) 한 곳에 모여 있습니다. 인증 PR은 이 메서드 본문만 spring-security-test 지원으로 바꿨고, 이를 쓰는 도메인 테스트 7개 파일·110여 개 호출부는 그대로 남았습니다.

제거된 것(되살리지 마세요 — 운영 인증 우회 구멍이 됩니다):

  • X-Debug-Member-Id 헤더 분기와 pinlog.auth.stub.enabled 프로퍼티
  • 순수 MVC 스텁 LoginMemberArgumentResolver. 실제 구현은 global/security/authentication에 있고 SecurityContext에서 principal을 꺼냅니다.

단일 PR 원칙

인증 PR은 다음을 모두 포함해야 병합할 수 있습니다. 하나라도 빠지면 병합하지 않습니다.

  1. Spring Security와 OAuth2 Client 의존성
  2. principal 계약 — 인증 주체를 컨트롤러가 받는 방식
  3. 보안 설정 — 공개/보호 경로, 인가 규칙, 쿠키 속성, CSRF 검증
  4. envelope 경계 — 인증 엔드포인트 성공 응답에 본문을 만들지 않는 것과, Security entry point의 오류 envelope
  5. 로컬 개발 방법 — 인증을 로컬에서 어떻게 통과시키는지
  6. 테스트 — 성공, 401 미인증, 404 권한, 403 CSRF, 쿠키 속성
  7. 문서 — 이 문서와 API 규약의 갱신

1. 의존성

Security 의존성은 인증 PR에서 처음 추가합니다. 그 전에는 build.gradle에 넣지 않습니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
// 소셜 로그인(Google·Kakao·Naver)이 확정이므로 선택이 아니라 필수다
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
testImplementation 'org.springframework.security:spring-security-test'

2. principal 계약

컨트롤러가 인증 주체를 받는 방식을 하나로 고정하고 문서화합니다. Entity를 그대로 principal로 노출하지 않습니다(API 규약의 DTO 분리 원칙).

  • 인증 주체 식별자는 member 도메인의 식별자(예: memberId)와 매핑합니다.
  • 인증이 필요한 엔드포인트는 익명 요청에서 principal에 의존하지 않도록 방어합니다.
  • 개인 API는 사용자 식별자를 query·body·경로로 받지 않습니다. 서버가 인증 쿠키로만 식별합니다(BD-14). 파라미터로 받으면 값을 바꿔 남의 데이터를 요청하는 경로가 열립니다.
  • principal 해석 실패(쿠키 없음/만료)는 401입니다. 자원 접근 권한 실패는 403이 아니라 404입니다(위 상태 코드 표). 403은 CSRF 실패에만 씁니다.

3. 보안 설정과 경로

SecurityConfigglobal/config에, 관련 필터·유틸은 global/security에 둡니다(패키지 구조 규약).

  • 서비스 context path는 /api/core이고 컨트롤러가 /v1을 붙여 기본 경로는 /api/core/v1 입니다. 보안 설정의 경로 매칭도 이 기준을 따릅니다(API 규약, infra/backend-conventions).
  • 공개 경로를 명시적으로 허용하고, 나머지는 인증을 요구합니다. 최소 다음은 공개로 유지합니다.
    • /api/core/actuator/health, /api/core/actuator/prometheus — 헬스체크·모니터링이 깨지면 배포가 실패합니다.
    • /api/core/v1/auth/{provider}/login, /api/core/v1/auth/{provider}/callback — 로그인 진입점 자체가 인증을 요구하면 로그인이 불가능합니다.
  • OAuth 콜백 URL은 context path와 /v1모두 포함합니다(/api/core/v1/auth/{provider}/callback). 어느 한쪽을 떼면 리다이렉트·OAuth 콜백·Swagger가 깨지고, 공급자 콘솔에 등록한 URL과도 어긋납니다.
  • 같은 콜백이 로그인과 탈퇴를 함께 받습니다. 탈퇴는 공급자 연결 해제에 쓸 access token을 얻으려고 인가를 한 번 더 받는데, 그때 돌아오는 곳이 이 경로입니다. 어느 쪽인지는 인가 요청 attributes가 정하고, 그 값을 넣는 것은 진입에서 서명 티켓을 검증한 WithdrawalAwareAuthorizationRequestResolver입니다(BD-48). 콜백 경로를 나누지 않은 이유는 redirect-uri공급자 콘솔 양쪽에서 바꿔야 하기 때문입니다.
  • 그래서 공급자 토큰 저장소도 요청 범위입니다(RequestScopedOAuth2AuthorizedClientRepository). Spring 기본값은 HttpSession을 만들어 아래 STATELESS 선언과 어긋나고, 이 토큰이 필요한 곳은 콜백 한 요청뿐입니다.
  • Refresh 쿠키의 Path=/api/core/v1/auth 범위를 지킵니다. 재발급과 로그아웃이 이 범위 안에 있어야 쿠키가 전송되고, 탈퇴(DELETE /api/core/v1/me)는 범위 밖이라 Access 쿠키로 식별합니다.

공통 응답 envelope의 예외

인증 엔드포인트의 성공 응답에는 envelope가 적용되지 않습니다. 공용 계약이 정한 응답이 전부 본문이 없기 때문입니다 — 콜백은 302+Set-Cookie, 재발급과 로그아웃은 204입니다(08_API_명세 §3.2~3.4).

그래서 제외 장치를 따로 만들 필요가 없습니다. global/web/ApiResponseBodyAdvicebody == null이면 그대로 null을 반환하므로, 본문 없는 응답은 domain 패키지 컨트롤러여도 감싸지지 않습니다(ApiResponseBodyAdviceTestvoidResponseHasEmptyBody·noContentEntityHasEmptyBody가 이 동작을 고정합니다). advice의 판정 조건을 건드리지 마세요 — 도메인 전체의 envelope 계약이 흔들립니다.

거꾸로 말하면 인증 엔드포인트가 성공 응답에 본문을 만드는 순간 envelope가 적용됩니다. 본문을 만들지 않는 것이 계약이고, 특히 토큰을 본문에 담지 않는 것은 쿠키를 택한 이유 그 자체입니다(BD-21).

오류 응답은 반대로 envelope를 지켜야 합니다(§1.5). GlobalExceptionHandler를 타는 예외는 자동으로 지켜지지만, Spring Security의 401·403 entry point·handler는 @RestControllerAdvice 이라 핸들러를 거치지 않습니다. 이 컴포넌트들은 ApiResponse.fail(...)을 직접 만들어 써야 합니다(에러 처리 규약).

4. 로컬 개발 방법

인증을 켠 뒤에도 로컬에서 개발·테스트가 가능해야 합니다. PR은 다음 중 하나 이상을 문서화합니다.

  • 로컬 프로파일에서 테스트용 사용자/토큰을 발급하는 방법, 또는
  • 통합 테스트에서 인증을 주입하는 방법(spring-security-test의 지원 활용).

로컬에서 인증을 통째로 우회하는 설정은 두지 않습니다. 인증 경로도 테스트 대상입니다.

5. 테스트 (필수)

인증·인가 변경은 테스트 규약에 따라 다음을 모두 테스트합니다.

경우 기대
정상 인증 요청 성공(2xx)
미인증 요청(쿠키 없음·만료) 401
남의 자원 접근 404 — 403이 아닙니다. 존재 여부를 노출하지 않습니다(BD-13)
상태 변경 요청에 X-XSRF-TOKEN 누락·불일치 403
회전 전 Refresh 재사용 401
탈퇴 판정이 DB 오류로 실패 503 — 401로 삼키면 클라이언트가 재로그인을 유도해 순단이 전면 로그아웃으로 번집니다(BD-47)
공개 경로(health/prometheus, 로그인 진입점) 인증 없이 접근 가능

403404를 정책 없이 섞지 않습니다. 위 표가 정책이며, 각 행에 테스트가 하나씩 대응해야 합니다.

쿠키 자체도 계약이므로 함께 검증합니다.

  • 발급 응답의 Set-CookieHttpOnly·Secure·SameSite=Lax가 있고, Refresh는 Path=/api/core/v1/auth인지
  • 응답 본문에 토큰 문자열이 없는지 — 쿠키를 택한 이유가 여기 있으므로 회귀로 고정합니다
  • 로그아웃 후 같은 Refresh 쿠키로 재발급이 401인지(회전·무효화가 실제로 동작하는지)
  • 재발급이 401일 때 쿠키 3종이 Max-Age=0으로 내려가는지 — 남기면 클라이언트가 죽은 토큰을 계속 보낸다(BT-06)

DB가 필요한 인증 테스트는 PostgreSQL Testcontainers를 사용합니다(H2 금지).

6. 문서 갱신

인증 PR은 이 문서를 실제 구현에 맞게 갱신하고, 인증이 포함된 API는 API 규약의 오류 계약(code/message/traceId)과 상태 코드를 함께 문서화합니다.

완료 조건 체크리스트

  • Security(및 필요 시 OAuth) 의존성 추가
  • principal 계약 정의·문서화 (Entity 직접 노출 금지, 사용자 식별자를 파라미터로 받지 않음)
  • SecurityConfig와 공개/보호 경로 설정, health·prometheus·로그인 진입점 공개 유지
  • 쿠키 속성(HttpOnly·Secure·SameSite=Lax)과 Refresh Path 범위 구현
  • CSRF 검증(XSRF-TOKENX-XSRF-TOKEN) 구현
  • envelope opt-out 장치 — 불필요함이 확인됐습니다(위 "공통 응답 envelope의 예외"). Security entry point의 오류 envelope는 SecurityErrorWriter가 만듭니다
  • 로컬 개발·테스트에서 인증 통과 방법 문서화 (아래 §7)
  • 성공 / 401 / 403(CSRF) / 공개 경로 / 쿠키 속성 / 본문 토큰 부재 테스트
  • 404(타인 자원 접근) — 도메인 API(Jira 작업~71)가 dev에 병합되면서 검증 대상이 생겼습니다. PublicCollectionApiTestswithdrawnOwnersCollectionIsHiddenFromOthers·unpublishedCollectionIsHiddenFromOthers가 실제 인증 위에서 404를 고정합니다(BD-13)
  • ./gradlew clean check --no-daemon 통과
  • 이 문서와 API 규약 갱신
  • 공용 계약(Team-PinLog/docs) static/08_API_명세.md 개정 — 별도 저장소라 이 PR 밖입니다

7. 구현된 형태 (Jira 작업)

여기부터는 계약이 아니라 실제로 이렇게 만들어졌다는 기록입니다. 계약과 어긋나면 위쪽이 이깁니다.

토큰과 키

서명은 RS256이고 키 관리 근거는 BD-31에 있습니다.

발급 JwtTokenProvider — Access·Refresh 모두 sub(memberId)·iss·exp·jti·token_use
키 공급 JwtKeyProviderpinlog.auth.jwt.private-key(PKCS#8 PEM). 공개키는 개인키에서 뽑습니다
키 없을 때 로컬·테스트는 임시 키쌍 생성, 운영 프로파일은 기동 실패
kid 공개키 thumbprint. 회전은 아직 구현하지 않았고 헤더만 선반영했습니다
검증 허용 알고리즘을 RS256으로 고정합니다. 토큰 헤더의 alg를 따라가지 않습니다(RFC 8725 §3.1)

token_use로 Access와 Refresh를 구분합니다. 같은 키로 서명하므로 이 구분이 없으면 30분짜리 토큰이 7일짜리 재발급 권한을 갖습니다.

만료에는 60초의 시계 오차 관용이 있습니다 — nimbus DefaultJWTClaimsVerifier의 기본값을 그대로 씁니다. 분산 환경에서 필요한 관용이라 두었고, 그만큼 실제 만료가 늦다는 뜻입니다.

쿠키

쿠키 속성 Path 읽는 주체
access_token HttpOnly·Secure·SameSite=Lax, 30분 /api/core 서버 — 모든 API 요청
refresh_token HttpOnly·Secure·SameSite=Lax, 7일 /api/core/v1/auth 서버 — 재발급·로그아웃만
logged_in Secure·SameSite=Lax, 7일. HttpOnly 아님 / 프론트 JS

logged_in은 UI 힌트 전용입니다. 인가 판단에 쓰지 마세요 — 값이 클라이언트에서 조작 가능합니다.

Path는 "누가 읽어야 하는가"로 정합니다. logged_in/인 이유가 여기 있습니다 — 프론트 페이지는 /·/auth/callback처럼 루트 아래에서 서비스되므로, API 경로로 좁히면 document.cookie에 나타나지 않아 읽을 방법이 없습니다. HttpOnly를 끄는 것만으로는 읽히지 않습니다(BT-04).

Secure를 로컬에서도 끄지 않습니다. 브라우저는 http://localhost를 신뢰할 수 있는 오리진으로 취급해 Secure 쿠키를 그대로 보냅니다.

Refresh 회전

RefreshTokenStore가 발급한 jti마다 Redis 키를 하나 두고(auth:refresh:{memberId}:{jti}), 재발급할 때 삭제로 소비합니다. 삭제의 반환값이 곧 검사 결과입니다 — 조회 후 삭제로 나누면 두 요청이 같은 토큰을 동시에 소비할 수 있습니다.

소비와 새 토큰 저장은 한 스크립트로 처리합니다(RefreshTokenStore.rotate). 둘로 나누면 그 사이에 재사용 감지의 폐기가 끼어, 먼저 도착한 요청이 발급한 토큰이 폐기가 끝난 뒤에 저장되어 살아남습니다. 폐기도 스크립트이므로 Redis가 둘을 직렬화합니다(BT-06).

회원당 하나가 아니라 jti당 하나인 이유는 다중 기기입니다. 회원당 한 개면 한쪽 재발급이 다른 쪽 세션을 끊습니다.

로그아웃은 멱등합니다. 이미 무효인 토큰으로 호출해도 204이고 쿠키는 항상 지웁니다. 여기서 401을 내면 클라이언트가 쿠키를 못 지운 채 남습니다.

재발급이 401이면 쿠키를 지웁니다. 401만 돌려주고 쿠키를 남기면 클라이언트는 죽은 Refresh를 계속 보내고, 그때마다 재사용으로 판정돼 폐기가 다시 돕니다 — 그 사이 새로 로그인한 세션까지 끊깁니다. logged_in도 남아 UI가 로그인 상태를 계속 가리키므로 빠져나갈 상태 전이가 없어집니다. 공용 계약이 "클라이언트는 재로그인으로 유도한다"(08 §3.3)고 정한 것을 서버가 쿠키로 뒷받침하는 것입니다.

지우는 곳은 컨트롤러입니다. GlobalExceptionHandler에서 지우면 만료된 Access로 보호 자원을 찍은 401까지 걸려, 재발급하면 될 상황에 세션을 끊습니다. 세션이 끝났다고 단정할 수 있는 곳은 재발급이 실패한 지점뿐입니다.

CSRF 토큰을 클라이언트가 얻는 방법

CsrfConfigurer.spa()의 토큰은 지연 로딩이라 조회 요청만으로는 XSRF-TOKEN 쿠키가 내려가지 않습니다. 그러면 클라이언트는 첫 상태 변경 요청에 넣을 토큰을 구할 방법이 없어 영영 403을 받습니다. CsrfCookieFilterCsrfFilter 뒤에서 토큰 해석을 강제해 아무 요청에나 XSRF-TOKEN 쿠키가 실려 나가도록 합니다.

프론트는 그 쿠키 값을 읽어 상태 변경 요청의 X-XSRF-TOKEN 헤더에 넣으면 됩니다.

principal

@LoginMember MemberPrincipal로 받습니다. LoginMemberArgumentResolverSecurityContext에서 꺼내고, 없으면 401입니다(fail-closed).

@GetMapping("/v1/collections")
public CursorPage<CollectionSummaryResponse> listMine(@LoginMember MemberPrincipal me) {
    return collectionService.listMine(me.memberId(), ...);   // 서비스는 Long을 받는다
}

도메인 브랜치와의 병합 주의. Jira 작업이 같은 이름의 스텁(X-Debug-Member-Id 헤더를 읽는 리졸버)을 먼저 만들어 뒀습니다. 병합할 때 스텁 쪽을 버리고 이 구현을 남겨야 합니다. 스텁의 헤더 분기와 pinlog.auth.stub.enabled 프로퍼티가 남으면 운영 인증 우회 구멍이 됩니다.

8. 로컬 개발과 테스트에서 인증 통과하기

인증을 우회하는 스위치는 없습니다. 아래는 모두 실제 인증 경로를 그대로 타는 방법입니다.

로컬 브라우저

compose.yaml의 PostgreSQL·Redis를 띄우고 앱을 실행한 뒤 http://localhost:8080/api/core/v1/auth/google/login으로 들어가면 됩니다. .envGOOGLE_CLIENT_ID·GOOGLE_CLIENT_SECRET이 있어야 하고, 없으면 인가 요청 URL 생성까지만 됩니다.

JWT_PRIVATE_KEY는 로컬에서 주지 않아도 됩니다. 기동할 때 임시 키쌍을 만들고 경고 로그를 남깁니다. 다만 재시작하면 기존 토큰이 전부 무효가 되니 다시 로그인해야 합니다. 고정하고 싶으면 PEM을 만들어 .env에 넣으면 됩니다.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-local.pem
# .env 에 한 줄로 (따옴표 없이):
# JWT_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----

따옴표로 감싸지 마세요. application.yml이 이 파일을 spring.config.import: optional:file:.env[.properties]로 올리므로 properties 형식으로 파싱됩니다. properties에서 따옴표는 구분자가 아니라 값의 일부이고, JwtKeyProvider.fromPem이 지우는 것은 공백뿐이어서 그 문자가 살아남아 base64 디코딩이 Illegal base64 character 22로 깨집니다. 같은 파서가 \n은 실제 개행으로 되돌리므로 개행 표기는 이 방식이 맞습니다.

운영 Secret은 반대입니다 — \n 두 글자가 아니라 실제 개행이 들어간 PEM 원문을 넣습니다(back#105). fromPem이 공백·개행을 모두 지우므로 원문이 그대로 통과합니다.

통합 테스트

AuthTokenContractTests처럼 로그인 흐름을 실제로 한 번 돌고 응답의 Set-Cookie에서 토큰을 꺼내 씁니다. 공급자는 StubOAuthProvider로 대역화하므로 네트워크가 필요 없습니다.

Redis가 필요한 테스트는 PostgresRedisContainerSupport를 상속합니다 — 로그인이 Refresh를 Redis에 저장하므로, 콜백을 타는 테스트는 Postgres만으로는 실패합니다.

쿠키를 CookieManager에 맡기지 말고 Set-Cookie 원문에서 꺼내 Cookie 헤더로 직접 넣으세요. Java CookieManager는 http 링크에 Secure 쿠키를 싣지 않아 왕복이 되지 않습니다.