From e0ba57b2bbc40926c5d4ce2962c54d4410c6543f Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 13:37:58 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=EC=9D=84=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=20=EA=B8=B0=EB=B0=98=20JWT=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=A0=95=20(=C2=A71.1=C2=B7=C2=A71.7=C2=B7=C2=A72.1?= =?UTF-8?q?=C2=B7=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §1.1 Bearer 헤더 → HttpOnly·Secure·SameSite 쿠키, Refresh 쿠키 Path 제한, memberId 미반환 - §1.5 상태 코드 표에 403 추가 (CSRF 실패) - §1.7 CSRF 절 신설 (XSRF-TOKEN 쿠키 → X-XSRF-TOKEN 헤더) - §2.1 POST /me/agreements 제거 - §3.2 SIGNUP_REQUIRED·signupToken 분기 제거, 콜백에서 즉시 회원 생성 후 Set-Cookie - §3.3 약관 동의 절 삭제, 이후 절 번호 조정 - §3.3 재발급을 쿠키 기반으로 전환하고 동시 재발급 금지 명시 인증 Endpoint는 302·204이거나 본문이 없어 공통 응답 봉투(§1.6)가 적용되지 않는다는 점을 각 절에 명시했다. Co-Authored-By: Claude Opus 5 (1M context) --- "static/08_API_\353\252\205\354\204\270.md" | 83 ++++++++++----------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index aef8df4..95863a1 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -17,10 +17,12 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - 방식: **JWT (확정)**. Access Token + Refresh Token. - 만료: Access **30분**, Refresh **7일**. - Refresh Token은 **Redis**에 저장한다(로그아웃 무효화·회전 발급 관리, TTL 자동 만료). -- 보호 Endpoint는 `Authorization: Bearer {accessToken}` 헤더가 필요하다. +- 토큰은 **`HttpOnly` + `Secure` + `SameSite` 쿠키**로 발급한다. 응답 본문에 토큰을 담지 않으며 클라이언트 스크립트는 토큰을 읽을 수 없다. +- 클라이언트는 요청에 자격증명을 포함시키기만 한다(`credentials: include` / `withCredentials`). 인증 헤더를 직접 구성하지 않는다. +- Refresh 쿠키는 `Path`를 재발급 경로로 제한해 일반 요청에 실리지 않게 한다. - Access 만료(401) 시 `POST /auth/refresh`로 재발급한다. -- 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 토큰으로 식별한다. -- 내부 사용자 ID는 공개 응답에 포함하지 않는다(로그인·가입 응답에서 본인 `memberId`를 받는 것만 예외). +- 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 쿠키로 식별한다. +- 내부 사용자 ID는 응답에 포함하지 않는다. 클라이언트는 자신의 `memberId`를 알 필요가 없다. ## 1.2 권한 실패 @@ -98,6 +100,7 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 | `204` | 응답 본문 없는 성공 | | `400` | 형식 또는 입력값 오류 | | `401` | 인증 필요 | +| `403` | CSRF 토큰 누락·불일치 (자원 접근 권한 실패는 1.2에 따라 `404`) | | `404` | 리소스 없음 또는 접근 권한 없음 | | `409` | 상태 충돌 (연쇄 삭제 확인 필요 등) | | `422` | 도메인 규칙 위반 | @@ -121,6 +124,15 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 - `success`는 HTTP 상태 코드를 대체하지 않는다. 상태 코드의 의미는 위 표를 그대로 따르며, `success: false`는 항상 4xx·5xx와 함께 온다. - 아래 3장 이후의 모든 응답 예시는 이 봉투를 적용한 형태다. +## 1.7 CSRF + +쿠키 기반 인증이므로 상태를 바꾸는 요청은 CSRF 토큰을 요구한다. + +- 서버가 `XSRF-TOKEN` 쿠키를 내려준다. 이 쿠키는 **`HttpOnly`가 아니며** 클라이언트가 읽을 수 있다. +- 클라이언트는 `POST`·`PUT`·`PATCH`·`DELETE` 요청에 그 값을 `X-XSRF-TOKEN` 헤더로 실어 보낸다. +- 헤더가 없거나 값이 일치하지 않으면 `403`을 반환한다. +- `GET`을 비롯한 조회 요청은 해당하지 않는다. + --- # 2. Endpoint 전체 목록 @@ -130,10 +142,9 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 | Method | Endpoint | 설명 | |---|---|---| | GET | `/auth/{provider}/login` | 소셜 로그인 시작 | -| GET | `/auth/{provider}/callback` | 소셜 로그인 콜백 (토큰 발급 또는 가입 분기) | +| GET | `/auth/{provider}/callback` | 소셜 로그인 콜백 (신규면 가입 처리 후 인증 쿠키 발급) | | POST | `/auth/refresh` | Access Token 재발급 | | POST | `/auth/logout` | 로그아웃 (Refresh Token 무효화) | -| POST | `/me/agreements` | 필수 약관 동의 + 가입 확정 | | GET | `/me/summary` | 마이페이지 요약 (계정 정보 + Record·Collection·팔로워·팔로잉 수) | | DELETE | `/me` | 회원 탈퇴 | @@ -231,68 +242,50 @@ naver GET /api/core/v1/auth/{provider}/callback?code={code}&state={state} ``` -공급자 인증 후 분기한다. +공급자 인증 후 다음을 수행한다. -기존 회원 (활성 `social_account` 존재): +- 활성 `social_account`가 있으면 그 회원으로 로그인한다. +- 없으면 이 시점에 `member`와 `social_account`를 생성한다. 소셜 인증 성공이 곧 가입 완료다. +- 두 경우 모두 인증 쿠키를 발급하고 클라이언트 애플리케이션으로 리다이렉트한다. -```json -{ "success": true, "data": { "status": "LOGIN", "memberId": 1201, "accessToken": "…", "refreshToken": "…" } } -``` +필수 약관은 클라이언트가 로그인 시작 이전 화면에서 안내하며, 서버는 동의 여부를 받지도 저장하지도 않는다. -신규 (가입 미완료): - -```json -{ "success": true, "data": { "status": "SIGNUP_REQUIRED", "signupToken": "…" } } -``` - -- 신규는 이 시점에 `member`를 생성하지 않는다. 약관 동의 화면으로 유도할 **가입 토큰**(단기, 예: 10분)만 발급한다. -- `signupToken`으로는 3.3 외 어떤 API도 호출할 수 없다. - -## 3.3 약관 동의 (가입 확정) +응답에 본문이 없다. 인증 정보는 `Set-Cookie`로만 전달하므로 공통 응답 봉투(1.6)가 적용되지 않는다. ```http -POST /api/core/v1/me/agreements -Authorization: Bearer {signupToken} +HTTP/1.1 302 Found +Location: {클라이언트 복귀 URL} +Set-Cookie: accessToken=…; Path=/api/core/v1 +Set-Cookie: refreshToken=…; Path=/api/core/v1/auth/refresh ``` -```json -{ - "agreed": true -} -``` - -- 필수 동의가 `true`가 아니면 400. -- 동작: `member` + `social_account` 생성(가입 확정) 후 토큰 발급. - -```json -{ "success": true, "data": { "memberId": 1201, "accessToken": "…", "refreshToken": "…" } } -``` +쿠키 속성은 1.1을 따른다. 복귀 URL은 배포 환경별 설정값이다. -201. +공급자 인증에 실패하면 실패를 나타내는 query와 함께 같은 복귀 URL로 리다이렉트한다. -## 3.4 토큰 재발급 +## 3.3 토큰 재발급 ```http POST /api/core/v1/auth/refresh ``` -```json -{ "refreshToken": "…" } -``` +요청 본문이 없다. Refresh 쿠키로 식별한다. + +- 200: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. +- 만료·무효, 또는 회전 전 Refresh 재사용: 401. 오류 응답은 봉투를 따른다(1.5). 클라이언트는 재로그인으로 유도한다. -- 200: `{ "success": true, "data": { "accessToken": "…", "refreshToken": "…" } }` (Refresh도 회전 발급) -- 만료·무효 Refresh: 401 → 프론트는 재로그인으로 유도. +회전 발급이므로 재발급 요청은 **동시에 하나만** 보낸다. 401이 여러 건 동시에 발생해도 재발급은 한 번만 호출하고 나머지 요청은 그 결과를 기다린다. -## 3.5 로그아웃 +## 3.4 로그아웃 ```http POST /api/core/v1/auth/logout ``` -- 동작: Refresh Token 무효화. Access는 만료로 자연 소멸. +- 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키를 만료시킨다. - 204. -## 3.6 마이페이지 요약 +## 3.5 마이페이지 요약 ```http GET /api/core/v1/me/summary @@ -316,7 +309,7 @@ GET /api/core/v1/me/summary - 카운트는 모두 활성 데이터 기준 집계다. - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. -- `memberId`는 로그인 응답(3.2 LOGIN, 3.3)에서 이미 전달되므로 여기서는 반환하지 않는다. +- `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1). --- From d5288cdd3564932096efabf67592431cfc7a65b2 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 13:38:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=EA=B0=80=EC=9E=85=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=C2=B7=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=95=ED=95=A9=20(02=C2=B704=C2=B706=C2=B709?= =?UTF-8?q?=C2=B710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 명세 §3.2 개정(소셜 인증 성공 시점에 회원 생성)에 맞춰 약관 동의를 로그인 시작 이전으로 옮긴 결과를 반영한다. - 06 §8: "동의 완료 전 member를 생성하지 않는다" 확정 항목을 뒤집고, 동의 증빙 수단이 서버에 남지 않는다는 영향을 명시 - 09 §1: 가입 플로우에서 동의 분기 제거, 약관을 로그인 이전 단계로 이동 - 10, 02, 04: 약관 안내 시점 표현 정리 Co-Authored-By: Claude Opus 5 (1M context) --- ...5_\354\240\225\354\235\230\354\204\234.md" | 2 +- ...65\352\260\234\354\240\225\354\261\205.md" | 2 +- ...7_\353\254\264\352\262\260\354\204\261.md" | 4 ++-- ...00\355\224\214\353\241\234\354\232\260.md" | 22 +++++++++---------- ...60\353\212\245\353\262\224\354\234\204.md" | 2 +- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git "a/static/02_\354\240\225\354\261\205_\354\240\225\354\235\230\354\204\234.md" "b/static/02_\354\240\225\354\261\205_\354\240\225\354\235\230\354\204\234.md" index 0e5d97e..0b16332 100644 --- "a/static/02_\354\240\225\354\261\205_\354\240\225\354\235\230\354\204\234.md" +++ "b/static/02_\354\240\225\354\261\205_\354\240\225\354\235\230\354\204\234.md" @@ -18,7 +18,7 @@ - 자체 아이디·비밀번호 로그인과 비밀번호 찾기·변경은 제공하지 않습니다. - 서비스 User와 SocialAccount를 분리합니다. - 소셜 인증 수단은 `provider + provider_user_id` 조합으로 식별합니다. -- 최초 가입 시 다음 내용을 안내하고 필수 약관 동의를 받습니다. +- 소셜 로그인을 시작하기 전에 다음 내용을 안내하고, 진행 시 필수 약관에 동의한 것으로 간주합니다. 동의는 클라이언트 화면에서 처리하며 서버는 동의 여부를 저장하지 않습니다. - Collection 생성 시 자동 발행 - Context 원문 비공개 - Place, Keyword, Record 생성일 등 공개 가능 정보 diff --git "a/static/04_\354\235\265\353\252\205SNS_\352\263\265\352\260\234\354\240\225\354\261\205.md" "b/static/04_\354\235\265\353\252\205SNS_\352\263\265\352\260\234\354\240\225\354\261\205.md" index 7f93a9a..803364a 100644 --- "a/static/04_\354\235\265\353\252\205SNS_\352\263\265\352\260\234\354\240\225\354\261\205.md" +++ "b/static/04_\354\235\265\353\252\205SNS_\352\263\265\352\260\234\354\240\225\354\261\205.md" @@ -29,7 +29,7 @@ Keyword는 사전 정의된 프리셋에서만 선택하며, 타인에게 공개 - Collection은 생성 즉시 자동 발행됩니다. - MVP에서는 발행 취소와 비공개 전환을 제공하지 않습니다. - 제목과 Record 구성 수정은 별도 스냅샷 없이 공개본에 반영됩니다. -- 자동 발행과 공개 범위는 최초 가입 약관 동의 화면에서 안내합니다. +- 자동 발행과 공개 범위는 로그인 시작 전 약관 안내 화면에서 안내합니다. ## 4. Shelf Follow diff --git "a/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" "b/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" index 4234024..50ffb7a 100644 --- "a/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" +++ "b/static/06_\353\215\260\354\235\264\355\204\260\353\252\250\353\215\270_\353\260\217_\353\254\264\352\262\260\354\204\261.md" @@ -589,8 +589,8 @@ member 소프트 삭제 | 항목 | 영향 | |---|---| -| 약관 동의 이력 테이블 도입 여부 | 정책 2.1의 필수 동의를 증빙할 수단이 현재 없음. 약관 종류·버전·시각 필요 | -| ~~가입 미완료(동의 전) 상태 처리~~ | **확정**: 동의 완료 전 `member`를 생성하지 않는다. 콜백에서 단기 가입 토큰만 발급하고, 약관 동의 시점에 `member`·`social_account`를 생성한다(API 명세 3.2~3.3) | +| 약관 동의 이력 테이블 도입 여부 | 정책 2.1의 필수 동의를 증빙할 수단이 없음. 동의를 클라이언트가 전담하므로 서버에 기록이 남지 않는다. 증빙이 필요해지면 동의 종류·버전·시각을 서버가 받는 경로부터 신설해야 한다 | +| ~~가입 미완료(동의 전) 상태 처리~~ | **확정**: 소셜 인증 성공 시점에 콜백에서 `member`·`social_account`를 생성한다. 필수 약관은 클라이언트가 로그인 시작 이전 화면에서 안내하므로 서버에 가입 미완료 상태가 존재하지 않는다(API 명세 3.2) | | Context 본문 최대 길이 | 임베딩 입력 길이 제한 및 비용과 직결 | | `is_published`의 MVP 활용 여부 | 비공개 전환을 제공하면 Feed·타인 Shelf 조회·Collection 상세 세 경로에 필터 필요. `record_count`의 정의(전체 vs 공개분)도 결정 필요 | | 소프트 삭제 데이터 보존 기간 | 미정 시 `collection_record`, `follow`가 무한 증식. 하드 삭제 배치 정책 필요 | diff --git "a/static/09_\354\234\240\354\240\200\355\224\214\353\241\234\354\232\260.md" "b/static/09_\354\234\240\354\240\200\355\224\214\353\241\234\354\232\260.md" index c5b51d6..96ef89b 100644 --- "a/static/09_\354\234\240\354\240\200\355\224\214\353\241\234\354\232\260.md" +++ "b/static/09_\354\234\240\354\240\200\355\224\214\353\241\234\354\232\260.md" @@ -16,20 +16,18 @@ ```mermaid flowchart TD - A[소셜 로그인 선택] --> B[Google·Kakao·Naver 인증] - B --> C{인증 성공?} - C -- 아니오 --> D[로그인 화면 복귀] - C -- 예 --> E{활성 SocialAccount 존재?} - E -- 예 --> F[로그인 완료] - E -- 아니오 --> G[필수 약관 안내] - G --> H{동의?} - H -- 아니오 --> I[가입 중단·계정 미생성] - H -- 예 --> J[Member·SocialAccount 생성] - J --> F + A[필수 약관 안내 · 진행 시 동의 간주] --> B[소셜 로그인 선택] + B --> C[Google·Kakao·Naver 인증] + C --> D{인증 성공?} + D -- 아니오 --> E[로그인 화면 복귀] + D -- 예 --> F{활성 SocialAccount 존재?} + F -- 예 --> G[로그인 완료] + F -- 아니오 --> H[Member·SocialAccount 생성] + H --> G ``` -- 회원가입은 소셜 인증만으로 완료되지 않습니다. **필수 동의까지 마쳐야 계정이 생성**됩니다. -- 동의 화면에서 이탈하면 아무 데이터도 남지 않습니다. 다시 진입하면 소셜 인증부터 시작합니다. +- 회원가입은 **소셜 인증 성공만으로 완료**됩니다. 별도의 가입 확정 단계가 없어 로그인과 가입이 같은 흐름입니다. +- 필수 약관은 소셜 로그인을 시작하기 **전** 화면에서 안내하며, 진행하면 동의한 것으로 간주합니다. 안내 화면에서 이탈하면 인증이 시작되지 않으므로 아무 데이터도 남지 않습니다. - 탈퇴한 사용자가 같은 소셜 계정으로 로그인하면 활성 SocialAccount가 없으므로 **신규 가입 흐름**을 탑니다. 과거 데이터는 복구되지 않습니다. - MVP는 소셜 로그인만 지원하므로 아이디 찾기, 비밀번호 찾기, 비밀번호 변경 흐름이 없습니다. - 로그아웃은 세션 종료 후 로그인 화면으로 돌아가는 단순 흐름이므로 별도 다이어그램을 두지 않습니다. diff --git "a/static/10_MVP_\352\270\260\353\212\245\353\262\224\354\234\204.md" "b/static/10_MVP_\352\270\260\353\212\245\353\262\224\354\234\204.md" index 90bdb71..ec71c3b 100644 --- "a/static/10_MVP_\352\270\260\353\212\245\353\262\224\354\234\204.md" +++ "b/static/10_MVP_\352\270\260\353\212\245\353\262\224\354\234\204.md" @@ -5,7 +5,7 @@ ### 계정 - Google, Kakao, Naver 소셜 로그인 -- 최초 로그인 시 User 생성과 필수 약관 동의 +- 최초 로그인 시 소셜 인증 성공 시점에 User 생성 (필수 약관은 로그인 시작 이전 화면에서 클라이언트가 안내) - 로그아웃, 회원 탈퇴 - User와 SocialAccount 분리 저장 From 630396baedd3e78e46d0c760c8069691bb4d98d2 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 13:50:54 +0900 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C(11)=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EA=B2=B0=EC=A0=95=20=EA=B7=BC=EA=B1=B0=C2=B7?= =?UTF-8?q?=EB=A0=88=ED=8D=BC=EB=9F=B0=EC=8A=A4=C2=B7=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EA=B3=84=EC=95=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프론트엔드와의 소통을 위해 인증 결정의 근거와 클라이언트가 지켜야 할 계약을 한 곳에 모은다. Endpoint 형태는 08 API 명세를 참조한다. - IETF draft-ietf-oauth-browser-based-apps의 아키텍처 등급과 BFF 쿠키 요구사항 - XSS·CSRF 맞교환과 쿠키를 택한 이유 - 클라이언트 계약: withCredentials, CSRF 헤더, 응답 봉투 예외, 401 처리와 동시 재발급 금지, 로그인은 페이지 이동 - 검토했으나 채택하지 않은 대안 4가지와 이유 - 합의 필요 항목 4가지 (확정 시 해당 절 삭제) - README 문서 구조 표에 항목 추가 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + ...0\354\246\235_\354\204\244\352\263\204.md" | 206 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 "static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" diff --git a/README.md b/README.md index 640221d..25466cc 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ PinLog은 장소를 `Context`와 함께 `Record`로 저장하고, 자연어로 | [API 명세](static/08_API_명세.md) | MVP Endpoint 목록 (상세 명세 흡수) | | [유저플로우](static/09_유저플로우.md) | 핵심 사용자 흐름 | | [MVP 기능범위](static/10_MVP_기능범위.md) | 포함·제외 기능 | +| [인증 설계](static/11_인증_설계.md) | 인증 방식 결정과 근거, 클라이언트 계약 | ## 기준 diff --git "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" new file mode 100644 index 0000000..d95bd60 --- /dev/null +++ "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" @@ -0,0 +1,206 @@ +# PinLog 인증 설계 + +인증 방식의 결정과 그 근거, 그리고 클라이언트가 지켜야 할 계약입니다. Endpoint의 요청·응답 형태는 [API 명세](08_API_명세.md) 1장과 3장을 따릅니다. 이 문서는 **왜 그렇게 정했는가**와 **클라이언트가 무엇을 해야 하는가**를 다룹니다. + +--- + +## 1. 결론 + +| 항목 | 결정 | +|---|---| +| 아키텍처 | BFF와 리소스 서버를 **한 서버**로 운영 | +| 인증 방식 | JWT. Access 30분 / Refresh 7일 | +| 토큰 전달 | **`HttpOnly` 쿠키**. 응답 본문에 토큰을 담지 않음 | +| 토큰 보관 | 클라이언트는 토큰을 보관하지도, 읽지도 않음 | +| Refresh | Redis 저장, **회전 발급**(재발급 시 이전 토큰 무효화) | +| CSRF | `XSRF-TOKEN` 쿠키 → `X-XSRF-TOKEN` 헤더 | +| 로그인 수단 | Google · Kakao · Naver 소셜 로그인만 | +| 가입 시점 | 소셜 인증 성공 시점에 즉시 회원 생성 | +| 약관 동의 | 클라이언트가 로그인 시작 **이전** 화면에서 전담 | + +--- + +## 2. 아키텍처 + +BFF(Backend For Frontend)와 리소스 서버가 **하나의 애플리케이션**입니다. 별도의 프록시 계층이 없고, `/api/core/v1`이 최종 목적지입니다. + +``` +[브라우저] ──①── [PinLog 서버 : BFF + 리소스] ──②── [Google/Kakao/Naver] + HttpOnly 쿠키 OAuth code ↔ token + (토큰은 JS에 노출되지 않음) client secret은 서버 밖으로 안 나감 +``` + +- **② 구간** — 공급자와의 OAuth는 서버가 수행합니다. client secret과 공급자 토큰은 서버 밖으로 나가지 않으며, 사용자 식별 직후 폐기하고 보관하지 않습니다. +- **① 구간** — 서버가 서명한 JWT를 쿠키로 내려줍니다. 클라이언트는 이 쿠키의 존재도 내용도 알 필요가 없습니다. + +--- + +## 3. 왜 쿠키인가 + +### 3.1 IETF 권고 + +IETF OAuth 워킹그룹의 [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)는 브라우저 앱 아키텍처를 **"decreasing order of security"**, 즉 보안이 강한 순서로 나열합니다. + +| 순위 | 패턴 | 문서 표현 | +|---|---|---| +| 1 | **BFF** | "strongly recommended for business applications, sensitive applications, and applications that **handle personal data**" | +| 2 | Token-mediating backend | "strongly recommended to **evaluate if adopting a full BFF** is a viable alternative" | +| 3 | Browser-based OAuth client | 공격자가 "long-term access to protected resources" 획득 가능 | + +PinLog은 개인의 장소 기록과 위치 데이터를 다루므로 1번이 지목하는 "personal data를 다루는 애플리케이션"에 해당합니다. + +같은 문서가 BFF의 쿠키에 요구하는 조건입니다. + +- `Secure` — **MUST** +- `HttpOnly` — **MUST** +- `SameSite` — **SHOULD**(제한적 값) +- CSRF 방어 — `SameSite` 제한 / 커스텀 요청 헤더 강제 / 프레임워크 anti-forgery 토큰 중 택일 + +즉 **"쿠키 + CSRF 대응"이 표준이 제시하는 정공법**입니다. 우회로가 아닙니다. + +### 3.2 XSS와 CSRF의 맞교환 + +토큰을 어디에 두느냐는 두 위협 사이의 선택입니다. + +| 보관 위치 | XSS(스크립트 주입) | CSRF | +|---|---|---| +| `HttpOnly` 쿠키 | **강함** — JS가 읽을 수 없음 | 약함 → 대응 필요 | +| `localStorage` + Bearer 헤더 | 약함 — 주입된 스크립트가 탈취 | 강함 — 대응 불필요 | + +쿠키를 택한 이유는 **피해의 크기가 다르기 때문**입니다. CSRF는 특정 요청 하나를 실행시키는 데 그치고 방어법이 정형화되어 있습니다. 반면 XSS로 토큰이 유출되면 공격자가 그 토큰으로 **임의의 API를 장기간** 호출할 수 있습니다. OWASP도 세션 식별자를 `localStorage`에 두지 말고 `httpOnly` 쿠키를 쓰라고 권고합니다. + +### 3.3 다만 쿠키도 만능은 아닙니다 + +IETF 문서는 **"malicious JavaScript code has the same privileges as the legitimate application code"** 라고 못박습니다. XSS가 뚫리면 토큰을 빼내지는 못해도 그 브라우저에서 요청을 대신 보내는 것은 가능합니다. + +그래서 토큰 보관 위치 논쟁보다 **XSS 자체를 막는 것**이 우선입니다. 문서가 권고하는 것은 nonce/hash 기반 Content Security Policy, 컨텍스트 인식 출력 인코딩, Subresource Integrity입니다. 프론트엔드 쪽 과제입니다. + +--- + +## 4. 클라이언트 계약 + +### 4.1 자격증명 포함 + +모든 요청에 자격증명을 포함시킵니다. 인증 헤더를 직접 구성하지 않습니다. + +```js +axios.defaults.withCredentials = true; // fetch면 credentials: 'include' +``` + +쿠키는 `HttpOnly`라 `document.cookie`로 읽히지 않습니다. **정상 동작입니다.** + +### 4.2 CSRF 헤더 + +상태를 바꾸는 요청(`POST`·`PUT`·`PATCH`·`DELETE`)에는 CSRF 토큰이 필요합니다. 없으면 `403`입니다. + +- 서버가 `XSRF-TOKEN` 쿠키를 내려줍니다. **이 쿠키만은 `HttpOnly`가 아니라** 읽을 수 있습니다. +- 그 값을 `X-XSRF-TOKEN` 헤더에 실어 보냅니다. +- axios는 이 이름들이 기본값이라 `withCredentials`만 켜면 대체로 자동입니다. `fetch`를 쓰면 직접 넣어야 합니다. +- 조회 요청은 해당하지 않습니다. + +### 4.3 응답 봉투 — 인증 Endpoint는 예외입니다 + +일반 API는 성공·오류 모두 공통 봉투(`success`/`data`/`error`)로 감쌉니다([API 명세](08_API_명세.md) 1.6). 하지만 **인증 Endpoint는 대부분 본문이 없습니다.** + +| Endpoint | 응답 | 봉투 | +|---|---|---| +| 로그인 시작·콜백 | `302` + `Set-Cookie` | 없음 | +| 재발급 성공 | `200` + `Set-Cookie`, 본문 없음 | 없음 | +| 로그아웃 | `204` | 없음 | +| 위 Endpoint의 **오류** | `401` 등 | **있음** (1.5) | + +즉 인증 흐름에서 `success` 필드를 보고 분기할 일이 없습니다. **HTTP 상태 코드로 판단**하세요. 오류가 났을 때만 봉투 안의 `error.code`를 봅니다. + +### 4.4 401 처리 — 재발급은 클라이언트가 호출합니다 + +BFF와 리소스 서버가 한 몸이므로, 서버가 요청 처리 도중 자기 토큰을 갱신하지 않습니다. + +``` +API 호출 → 401 + → POST /api/core/v1/auth/refresh (본문 없음, 쿠키로 동작) + ├ 200 → 새 쿠키 자동 저장 → 원래 요청 재시도 + └ 401 → 로그인 화면으로 +``` + +**재발급 요청은 동시에 하나만 보냅니다.** Refresh Token은 회전 발급이라 재발급할 때마다 이전 토큰이 무효가 되고, 무효 토큰 재사용은 `401`입니다. 화면 진입 시 API를 여러 개 동시에 쏘면 `401`이 여러 건 터지는데, 재발급을 각각 호출하면 첫 요청이 토큰을 회전시켜 나머지가 실패하고 **사용자가 로그아웃됩니다.** 재발급은 하나의 처리로 묶고 나머지 요청은 그 결과를 기다려야 합니다. + +재발급 요청 자체의 `401`은 재시도 대상에서 제외합니다. 무한 루프가 됩니다. + +### 4.5 로그인 진입 + +로그인 시작은 **페이지 이동**입니다. `fetch`나 `axios`로 호출하면 공급자 리다이렉트가 동작하지 않습니다. + +```js +window.location.href = '/api/core/v1/auth/google/login'; +``` + +### 4.6 자신의 ID를 받지 않습니다 + +서버는 응답에 `memberId`를 포함하지 않습니다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없고, 알아서도 안 됩니다. + +--- + +## 5. 흐름 + +### 5.1 로그인·가입 + +로그인과 가입은 같은 흐름입니다. 신규 여부는 서버가 판단하고 클라이언트는 구분하지 않습니다. + +``` +1. (사전) 필수 약관 안내 화면 — 진행 시 동의 간주 +2. window.location → GET /auth/{provider}/login +3. 공급자 인가 페이지에서 사용자 인증 +4. 공급자 → GET /auth/{provider}/callback +5. 서버: 활성 소셜 계정이 있으면 로그인, 없으면 회원 생성 +6. 서버: 인증 쿠키 발급 + 클라이언트 복귀 URL로 302 +7. 클라이언트: 착지. 추가 호출 없음 +``` + +**토큰 교환 단계가 없습니다.** 클라이언트가 code나 토큰을 다루는 지점이 존재하지 않습니다. + +공급자 인증에 실패하면 실패를 나타내는 query와 함께 같은 복귀 URL로 돌아옵니다. + +### 5.2 로그아웃 + +``` +POST /api/core/v1/auth/logout + → 서버: Refresh Token 무효화 + 쿠키 만료 + → 204 + → 클라이언트: 로그인 화면으로 +``` + +--- + +## 6. 검토한 대안과 채택하지 않은 이유 + +| 대안 | 채택하지 않은 이유 | +|---|---| +| **Bearer 헤더 + `localStorage`** | IETF 분류상 "token-mediating backend" 이하에 해당합니다. BFF를 도입하는 목적 자체가 토큰을 브라우저에서 치우는 것인데, 토큰을 다시 내려주면 그 이점이 상쇄됩니다 | +| **서버 세션 + 세션 ID** | 구현이 가장 단순하고 로그아웃도 확실하지만, 향후 클라이언트 확장(네이티브 앱 등)을 고려해 JWT를 유지했습니다 | +| **Access는 메모리 · Refresh만 쿠키** | Access가 XSS에 노출되는 구간이 남습니다. 두 토큰을 모두 쿠키에 두는 편이 단순하고 일관됩니다 | +| **약관 동의 API로 가입 확정** | 서버에 "가입 미완료" 중간 상태와 단기 가입 토큰이 필요해 흐름이 복잡해집니다. 약관을 로그인 이전으로 옮겨 이 상태를 없앴습니다 | + +--- + +## 7. 합의 필요 항목 + +아래 네 가지는 아직 정해지지 않았습니다. 정해지면 이 절을 지우고 해당 내용을 본문과 [API 명세](08_API_명세.md)에 반영합니다. + +| 항목 | 왜 필요한가 | 정할 주체 | +|---|---|---| +| **`SameSite` 값** (`Lax` / `Strict` / `None`) | 프론트와 API의 운영 도메인이 같은지에 달렸습니다. 다르면 `None`+`Secure`가 강제되고 CORS `allowCredentials` 설정이 따라옵니다. `Strict`로 두면 외부 링크를 타고 처음 들어올 때 로그아웃 상태로 보입니다 | 백엔드 · 인프라 · 프론트 | +| **콜백 복귀 URL** | 5.1의 6번에서 서버가 어디로 리다이렉트할지. 성공·실패 경로와, 로그인 이전 페이지로 복귀시킬지 여부 | 프론트 | +| **로그인 상태 확인 방식** | `HttpOnly` 쿠키는 읽을 수 없어 앱 시작 시 로그인 여부를 알 방법이 없습니다. 전용 확인 Endpoint를 두거나, 민감정보 없는 표시용 비-`HttpOnly` 쿠키를 함께 발급하는 방법이 있습니다. `GET /me/summary`는 집계 API라 이 용도에 적합하지 않습니다 | 프론트 · 백엔드 | +| **회원 탈퇴 상세** | `DELETE /me`의 동작 상세가 명세에 없습니다. 소프트 삭제와 마스킹 범위를 확정해야 합니다 | 백엔드 | + +> 로컬 개발 환경(`localhost:3000` ↔ `localhost:8080`)은 포트가 달라도 **same-site**라 `SameSite=Lax` 쿠키가 정상 전송됩니다. 다만 origin은 다르므로 **CORS 설정은 로컬에서도 필요**합니다. + +--- + +## 8. 레퍼런스 + +- [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) — IETF OAuth WG. 아키텍처 등급, BFF 쿠키 요구사항, XSS 대응 +- [OAuth 2.0 Security Best Current Practice (RFC 9700)](https://www.rfc-editor.org/rfc/rfc9700.html) — PKCE, 토큰 수명, 발신자 제약 +- [OWASP HTML5 Security Cheat Sheet — Local Storage](https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#local-storage) — 세션 식별자를 `localStorage`에 두지 말 것 +- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) — 토큰 패턴, `SameSite` +- [MDN — Set-Cookie: SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) — same-site 판정에 포트가 포함되지 않음 From f3f51bfc76798eb119a059062e8868fcd6f221a5 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 14:36:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=EC=9D=B8=EC=A6=9D=20=EB=AF=B8?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=20=ED=95=AD=EB=AA=A9=204=EA=B1=B4=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20(SameSite=C2=B7=EB=B3=B5=EA=B7=80=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=C2=B7=EC=84=B8=EC=85=98=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=C2=B7=ED=83=88=ED=87=B4=20=EC=83=81=EC=84=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra의 Traefik 경로 라우팅 구성상 프론트와 API가 같은 오리진이므로 CORS와 SameSite=None이 불필요하다는 사실을 근거로 값을 확정한다. 08 API 명세: - §1.1 SameSite=Lax 명시, same-origin이라 CORS 불필요함을 기재 - §2.1·§3.5 GET /auth/session 신설 (200/401, 앱 시작 시 로그인 판단) - §3.2 복귀 경로를 /auth/callback으로 고정. 요청 파라미터로 받지 않는다 (open redirect 방지). 실패는 ?error= 부착 - §3.7 회원 탈퇴 상세 신설 — 정책 10장 기준 연쇄 소프트 삭제, provider_user_id·email 마스킹, 재가입 시 신규 회원 11 인증 설계: - 1장 결론 표에 확정값 3건 추가 - 5.2 앱 시작 흐름 추가 - 7장을 "합의 필요 항목"에서 "배포 오리진과 쿠키"로 교체. Strict가 아닌 Lax인 이유(공유 링크 진입 시 로그아웃으로 보이는 문제)와 로컬 개발 시 same-site·cross-origin 구분을 기재 Co-Authored-By: Claude Opus 5 (1M context) --- "static/08_API_\353\252\205\354\204\270.md" | 64 +++++++++++++++++-- ...0\354\246\235_\354\204\244\352\263\204.md" | 58 +++++++++++++---- 2 files changed, 106 insertions(+), 16 deletions(-) diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index 95863a1..f0031fb 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -17,9 +17,10 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - 방식: **JWT (확정)**. Access Token + Refresh Token. - 만료: Access **30분**, Refresh **7일**. - Refresh Token은 **Redis**에 저장한다(로그아웃 무효화·회전 발급 관리, TTL 자동 만료). -- 토큰은 **`HttpOnly` + `Secure` + `SameSite` 쿠키**로 발급한다. 응답 본문에 토큰을 담지 않으며 클라이언트 스크립트는 토큰을 읽을 수 없다. +- 토큰은 **`HttpOnly` + `Secure` + `SameSite=Lax` 쿠키**로 발급한다. 응답 본문에 토큰을 담지 않으며 클라이언트 스크립트는 토큰을 읽을 수 없다. - 클라이언트는 요청에 자격증명을 포함시키기만 한다(`credentials: include` / `withCredentials`). 인증 헤더를 직접 구성하지 않는다. - Refresh 쿠키는 `Path`를 재발급 경로로 제한해 일반 요청에 실리지 않게 한다. +- 프론트엔드와 API는 같은 오리진에서 서비스한다. 따라서 `SameSite=None`과 CORS 자격증명 설정이 필요하지 않다. - Access 만료(401) 시 `POST /auth/refresh`로 재발급한다. - 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 쿠키로 식별한다. - 내부 사용자 ID는 응답에 포함하지 않는다. 클라이언트는 자신의 `memberId`를 알 필요가 없다. @@ -145,6 +146,7 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 | GET | `/auth/{provider}/callback` | 소셜 로그인 콜백 (신규면 가입 처리 후 인증 쿠키 발급) | | POST | `/auth/refresh` | Access Token 재발급 | | POST | `/auth/logout` | 로그아웃 (Refresh Token 무효화) | +| GET | `/auth/session` | 로그인 여부 확인 (앱 시작 시 1회) | | GET | `/me/summary` | 마이페이지 요약 (계정 정보 + Record·Collection·팔로워·팔로잉 수) | | DELETE | `/me` | 회원 탈퇴 | @@ -254,14 +256,22 @@ GET /api/core/v1/auth/{provider}/callback?code={code}&state={state} ```http HTTP/1.1 302 Found -Location: {클라이언트 복귀 URL} +Location: /auth/callback Set-Cookie: accessToken=…; Path=/api/core/v1 Set-Cookie: refreshToken=…; Path=/api/core/v1/auth/refresh ``` -쿠키 속성은 1.1을 따른다. 복귀 URL은 배포 환경별 설정값이다. +쿠키 속성은 1.1을 따른다. -공급자 인증에 실패하면 실패를 나타내는 query와 함께 같은 복귀 URL로 리다이렉트한다. +복귀 경로는 성공·실패 모두 `/auth/callback` 하나이며, 실패 시에만 `error` query가 붙는다. + +```text +성공: /auth/callback +실패: /auth/callback?error=OAUTH_FAILED +``` + +- 복귀 경로는 **서버 설정값**이며 요청 파라미터로 받지 않는다. 임의 URL을 받으면 open redirect 취약점이 된다. +- 로그인 이전 화면으로 되돌아가는 처리는 클라이언트가 담당한다(로그인 시작 전 경로를 `sessionStorage` 등에 보관). ## 3.3 토큰 재발급 @@ -285,7 +295,29 @@ POST /api/core/v1/auth/logout - 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키를 만료시킨다. - 204. -## 3.5 마이페이지 요약 +## 3.5 세션 확인 + +```http +GET /api/core/v1/auth/session +``` + +앱 시작 시 로그인 여부를 판단하기 위해 1회 호출한다. 인증 쿠키는 `HttpOnly`라 클라이언트가 직접 읽을 수 없으므로 이 Endpoint가 유일한 판단 근거다. + +- 200: 로그인 상태다. + +```json +{ + "success": true, + "data": { + "authenticated": true + } +} +``` + +- 401: 로그인 상태가 아니다. 보호 Endpoint이므로 Access가 만료됐으면 평소와 같이 재발급(3.3)을 시도하고, 그것도 실패하면 로그인 화면으로 유도한다. +- 집계나 조인이 없는 가벼운 호출이다. 로그인 판단에 `GET /me/summary`(3.6)를 사용하지 않는다. + +## 3.6 마이페이지 요약 ```http GET /api/core/v1/me/summary @@ -311,6 +343,28 @@ GET /api/core/v1/me/summary - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. - `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1). +## 3.7 회원 탈퇴 + +```http +DELETE /api/core/v1/me +``` + +- 204. 응답 본문이 없다. +- 되돌릴 수 없다. 클라이언트는 실행 전 확인 절차를 둔다. + +동작은 정책 정의서 10장을 따른다. + +| 대상 | 처리 | +|---|---| +| `member`, `social_account` | 소프트 삭제 | +| `record`, `context`, `collection`, `collection_record`, 관련 `follow` | 소프트 삭제 | +| `social_account`의 `provider_user_id`, `email` | **마스킹**(개인정보 파기 대상) | +| Refresh Token | 무효화하고 인증 쿠키를 만료시킨다 | +| `place` | 공용 데이터이므로 유지한다 | + +- 탈퇴한 사용자의 Shelf와 Collection은 다른 사용자의 Library·Feed에서 즉시 제외한다. +- 활성 `social_account`가 사라지므로, 같은 소셜 계정으로 다시 로그인하면 **신규 회원으로 가입**된다(3.2). 과거 데이터는 복구되지 않는다. + --- # 4. Place·지도 상세 diff --git "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" index d95bd60..31bb92a 100644 --- "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" +++ "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" @@ -10,10 +10,13 @@ |---|---| | 아키텍처 | BFF와 리소스 서버를 **한 서버**로 운영 | | 인증 방식 | JWT. Access 30분 / Refresh 7일 | -| 토큰 전달 | **`HttpOnly` 쿠키**. 응답 본문에 토큰을 담지 않음 | +| 토큰 전달 | **`HttpOnly` + `Secure` + `SameSite=Lax` 쿠키**. 응답 본문에 토큰을 담지 않음 | | 토큰 보관 | 클라이언트는 토큰을 보관하지도, 읽지도 않음 | | Refresh | Redis 저장, **회전 발급**(재발급 시 이전 토큰 무효화) | | CSRF | `XSRF-TOKEN` 쿠키 → `X-XSRF-TOKEN` 헤더 | +| 배포 오리진 | 프론트와 API가 **같은 오리진**. CORS 불필요 | +| 로그인 상태 확인 | `GET /auth/session` (200 / 401) | +| 콜백 복귀 경로 | `/auth/callback` (실패 시 `?error=`) | | 로그인 수단 | Google · Kakao · Naver 소셜 로그인만 | | 가입 시점 | 소셜 인증 성공 시점에 즉시 회원 생성 | | 약관 동의 | 클라이언트가 로그인 시작 **이전** 화면에서 전담 | @@ -107,6 +110,7 @@ axios.defaults.withCredentials = true; // fetch면 credentials: 'include' | 로그인 시작·콜백 | `302` + `Set-Cookie` | 없음 | | 재발급 성공 | `200` + `Set-Cookie`, 본문 없음 | 없음 | | 로그아웃 | `204` | 없음 | +| 세션 확인 | `200` + `{ authenticated: true }` | **있음** | | 위 Endpoint의 **오류** | `401` 등 | **있음** (1.5) | 즉 인증 흐름에서 `success` 필드를 보고 분기할 일이 없습니다. **HTTP 상태 코드로 판단**하세요. 오류가 났을 때만 봉투 안의 `error.code`를 봅니다. @@ -160,7 +164,19 @@ window.location.href = '/api/core/v1/auth/google/login'; 공급자 인증에 실패하면 실패를 나타내는 query와 함께 같은 복귀 URL로 돌아옵니다. -### 5.2 로그아웃 +### 5.2 앱 시작 + +``` +GET /api/core/v1/auth/session + ├ 200 → 로그인 상태. 메인 화면 + └ 401 → 재발급(4.4) 시도 + ├ 성공 → 재호출 후 메인 화면 + └ 실패 → 로그인 화면 +``` + +`HttpOnly` 쿠키는 읽을 수 없으므로 이 호출이 로그인 여부의 유일한 판단 근거입니다. 집계가 없는 가벼운 Endpoint라 앱 시작 시 1회 호출해도 부담이 없습니다. + +### 5.3 로그아웃 ``` POST /api/core/v1/auth/logout @@ -182,18 +198,38 @@ POST /api/core/v1/auth/logout --- -## 7. 합의 필요 항목 +## 7. 배포 오리진과 쿠키 -아래 네 가지는 아직 정해지지 않았습니다. 정해지면 이 절을 지우고 해당 내용을 본문과 [API 명세](08_API_명세.md)에 반영합니다. +### 7.1 운영 — 같은 오리진 -| 항목 | 왜 필요한가 | 정할 주체 | -|---|---|---| -| **`SameSite` 값** (`Lax` / `Strict` / `None`) | 프론트와 API의 운영 도메인이 같은지에 달렸습니다. 다르면 `None`+`Secure`가 강제되고 CORS `allowCredentials` 설정이 따라옵니다. `Strict`로 두면 외부 링크를 타고 처음 들어올 때 로그아웃 상태로 보입니다 | 백엔드 · 인프라 · 프론트 | -| **콜백 복귀 URL** | 5.1의 6번에서 서버가 어디로 리다이렉트할지. 성공·실패 경로와, 로그인 이전 페이지로 복귀시킬지 여부 | 프론트 | -| **로그인 상태 확인 방식** | `HttpOnly` 쿠키는 읽을 수 없어 앱 시작 시 로그인 여부를 알 방법이 없습니다. 전용 확인 Endpoint를 두거나, 민감정보 없는 표시용 비-`HttpOnly` 쿠키를 함께 발급하는 방법이 있습니다. `GET /me/summary`는 집계 API라 이 용도에 적합하지 않습니다 | 프론트 · 백엔드 | -| **회원 탈퇴 상세** | `DELETE /me`의 동작 상세가 명세에 없습니다. 소프트 삭제와 마스킹 범위를 확정해야 합니다 | 백엔드 | +Traefik이 한 호스트에서 경로로 라우팅합니다. + +``` +https://{서비스 호스트}/ → 프론트엔드 +https://{서비스 호스트}/api/core/ → PinLog 서버 +``` + +스킴·호스트·포트가 모두 같으므로 **same-origin**입니다. 따라서: + +- **CORS 설정이 필요 없습니다.** `Access-Control-Allow-Credentials`도, 프리플라이트도 발생하지 않습니다 +- `SameSite=None`이 필요 없습니다. 쿠키가 항상 자기 오리진으로만 갑니다 + +### 7.2 `SameSite`는 `Lax`입니다 + +`Strict`가 아닌 이유는 **공유 링크** 때문입니다. PinLog은 Collection 링크를 주고받는 발견형 서비스인데, `Strict`로 두면 메신저나 다른 사이트에서 링크를 타고 들어온 첫 요청에 쿠키가 실리지 않아 **로그인한 사용자가 로그아웃 상태로 보입니다.** + +IETF 문서는 BFF에 `SameSite=Strict`를 SHOULD로 권고하지만, 같은 문서가 CSRF 방어 수단으로 `SameSite` 제한 **또는** anti-forgery 토큰을 제시합니다. 우리는 CSRF 토큰(4.2)을 쓰므로 `Lax`로 두어도 방어가 유지됩니다. 소셜 로그인 콜백이 크로스 사이트 이동이라는 점에서도 `Lax`가 자연스럽습니다. + +### 7.3 로컬 개발 + +``` +프론트 http://localhost:3000 +API http://localhost:8080/api/core +``` -> 로컬 개발 환경(`localhost:3000` ↔ `localhost:8080`)은 포트가 달라도 **same-site**라 `SameSite=Lax` 쿠키가 정상 전송됩니다. 다만 origin은 다르므로 **CORS 설정은 로컬에서도 필요**합니다. +- 포트가 달라도 **same-site**입니다(`SameSite` 판정에 포트가 들어가지 않음). `Lax` 쿠키가 정상 전송됩니다 +- 다만 origin은 다르므로 **CORS 설정이 로컬에서만 필요**합니다. 운영에는 해당하지 않습니다 +- 최신 브라우저는 `localhost`를 신뢰 컨텍스트로 취급해 `http`에서도 `Secure` 쿠키가 동작합니다. `127.0.0.1`이나 LAN IP 대신 `localhost` 이름으로 접속하세요 --- From b470120d975e1f99e9dc89c7319d1f217479a19d Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 14:47:22 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=EA=B8=B0=EB=B3=B8=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EC=A0=95=20=E2=80=94?= =?UTF-8?q?=20context-path=EC=99=80=20API=20=EB=B2=84=EC=A0=84=EC=9D=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=B4=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서 헤더는 `기본 경로: /api/core/`인데 3장 이후 Endpoint는 모두 `/api/core/v1`을 써서 어긋나 보였다. 실제로는 `/api/core`가 인프라가 고정한 서비스 context-path이고 `v1`은 그 뒤에 붙는 API 버전 세그먼트라 둘 다 맞다. 관계를 드러내도록 표기를 고친다. - 헤더에서 context-path와 API 버전을 각각 적고 합친 기본 경로를 명시 - 2장 목록의 Endpoint가 기본 경로 뒤에 붙는 상대 경로임을 안내 Co-Authored-By: Claude Opus 5 (1M context) --- "static/08_API_\353\252\205\354\204\270.md" | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index f0031fb..05acb0e 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -2,7 +2,9 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 문서를, 화면 흐름은 유저플로우 문서를 따릅니다. -- 기본 경로: `/api/core/` +- 서비스 context-path: `/api/core` (인프라 고정값. 컨트롤러 매핑에 다시 쓰지 않는다) +- API 버전: `v1` — context-path 뒤에 붙는 버전 세그먼트 +- **기본 경로: `/api/core/v1`** (위 둘을 합친 값. 2장 목록의 Endpoint는 여기에 이어 붙는 상대 경로다) - 응답 형식: 공통 봉투(`success`/`data`/`error`) — 1.6 - 페이지네이션: 커서 기반 - 시간 형식: ISO 8601 UTC @@ -138,6 +140,8 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 # 2. Endpoint 전체 목록 +아래 표의 Endpoint는 모두 기본 경로 `/api/core/v1` 뒤에 붙는 상대 경로다. 예를 들어 `/auth/logout`의 전체 경로는 `/api/core/v1/auth/logout`이다. 3장 이후의 상세에서는 전체 경로로 표기한다. + ## 2.1 인증·계정 | Method | Endpoint | 설명 | From 3dd26f55fd30e660b4f37b306fd3d4ea8aea84d7 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 14:55:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=ED=99=95=EC=9D=B8=EC=9D=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=BF=A0=ED=82=A4=EB=A1=9C,=20base=20URL=20?= =?UTF-8?q?=EA=B0=92=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #12 리뷰(TrossYou) 회신 반영. 로그인 상태 확인 (B안 채택): - GET /auth/session Endpoint를 철회하고 logged_in 표시 쿠키로 대체 - 08 §1.8 신설 — 쿠키 속성·발급·삭제 시점과 "UI 힌트 전용, 인가 판단 금지, 실제 인가는 서버가 매 요청 검증" 경고를 명시 - 콜백·재발급·로그아웃·탈퇴 각 절에 표시 쿠키 처리 추가 - 11 §5.2 앱 시작 흐름을 쿠키 기반으로 재작성 base URL: - 08 헤더에 "클라이언트 API base URL 환경변수에는 /api/core/v1을 넣는다" 명시. 본문 Endpoint 37곳은 이미 전부 /api/core/v1로 통일되어 있어 변경 없음 SameSite=Lax와 콜백 경로 /auth/callback은 프론트 의견과 일치하여 유지. Co-Authored-By: Claude Opus 5 (1M context) --- "static/08_API_\353\252\205\354\204\270.md" | 56 +++++++++---------- ...0\354\246\235_\354\204\244\352\263\204.md" | 24 +++++--- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index 05acb0e..af96844 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -5,6 +5,7 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - 서비스 context-path: `/api/core` (인프라 고정값. 컨트롤러 매핑에 다시 쓰지 않는다) - API 버전: `v1` — context-path 뒤에 붙는 버전 세그먼트 - **기본 경로: `/api/core/v1`** (위 둘을 합친 값. 2장 목록의 Endpoint는 여기에 이어 붙는 상대 경로다) + - 클라이언트의 API base URL 환경변수에는 이 값을 넣는다. 3장 이후 모든 예시가 이 경로로 시작한다 - 응답 형식: 공통 봉투(`success`/`data`/`error`) — 1.6 - 페이지네이션: 커서 기반 - 시간 형식: ISO 8601 UTC @@ -23,6 +24,7 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - 클라이언트는 요청에 자격증명을 포함시키기만 한다(`credentials: include` / `withCredentials`). 인증 헤더를 직접 구성하지 않는다. - Refresh 쿠키는 `Path`를 재발급 경로로 제한해 일반 요청에 실리지 않게 한다. - 프론트엔드와 API는 같은 오리진에서 서비스한다. 따라서 `SameSite=None`과 CORS 자격증명 설정이 필요하지 않다. +- 인증 쿠키와 별개로, 클라이언트가 로그인 여부를 판단할 수 있도록 **표시용 쿠키**를 함께 발급한다(1.8). - Access 만료(401) 시 `POST /auth/refresh`로 재발급한다. - 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 쿠키로 식별한다. - 내부 사용자 ID는 응답에 포함하지 않는다. 클라이언트는 자신의 `memberId`를 알 필요가 없다. @@ -136,6 +138,24 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 - 헤더가 없거나 값이 일치하지 않으면 `403`을 반환한다. - `GET`을 비롯한 조회 요청은 해당하지 않는다. +## 1.8 로그인 표시 쿠키 + +인증 쿠키는 `HttpOnly`라 클라이언트가 읽을 수 없다. 앱 시작 시 로그인 화면을 띄울지 판단할 수 있도록, 값에 의미가 없는 표시용 쿠키를 함께 발급한다. + +| 항목 | 값 | +|---|---| +| 이름·값 | `logged_in=1` | +| 속성 | `Secure`, `SameSite=Lax`, `Path=/`, `Max-Age`는 Refresh와 동일(7일). **`HttpOnly`가 아니다** | +| 내용 | 개인정보·식별자를 담지 않는다. 존재 여부만 의미가 있다 | +| 발급·갱신 | 로그인 콜백(3.2), 재발급 성공(3.3) | +| 삭제 | 로그아웃(3.4), 회원 탈퇴(3.6) | + +> **UI 힌트 전용이다. 인가 판단에 사용하지 않는다.** +> +> 실제 인가는 서버가 **매 요청** 인증 쿠키를 검증해 수행한다. 이 쿠키는 브라우저에 남아 있어도 세션이 이미 무효일 수 있다(예: Refresh 만료, 다른 기기에서 로그아웃). 그 경우 첫 API 호출이 `401`을 반환하므로, 클라이언트는 재발급(3.3)을 시도하고 실패하면 로그인 화면으로 유도한다. +> +> 이 쿠키의 존재를 근거로 보호 화면을 렌더링하는 것은 무방하다. 이 쿠키의 존재를 근거로 **권한이 있다고 판단하는 것은 안 된다.** + --- # 2. Endpoint 전체 목록 @@ -150,7 +170,6 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 | GET | `/auth/{provider}/callback` | 소셜 로그인 콜백 (신규면 가입 처리 후 인증 쿠키 발급) | | POST | `/auth/refresh` | Access Token 재발급 | | POST | `/auth/logout` | 로그아웃 (Refresh Token 무효화) | -| GET | `/auth/session` | 로그인 여부 확인 (앱 시작 시 1회) | | GET | `/me/summary` | 마이페이지 요약 (계정 정보 + Record·Collection·팔로워·팔로잉 수) | | DELETE | `/me` | 회원 탈퇴 | @@ -263,9 +282,10 @@ HTTP/1.1 302 Found Location: /auth/callback Set-Cookie: accessToken=…; Path=/api/core/v1 Set-Cookie: refreshToken=…; Path=/api/core/v1/auth/refresh +Set-Cookie: logged_in=1; Path=/ ``` -쿠키 속성은 1.1을 따른다. +인증 쿠키 속성은 1.1, 표시 쿠키는 1.8을 따른다. 복귀 경로는 성공·실패 모두 `/auth/callback` 하나이며, 실패 시에만 `error` query가 붙는다. @@ -285,7 +305,7 @@ POST /api/core/v1/auth/refresh 요청 본문이 없다. Refresh 쿠키로 식별한다. -- 200: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. +- 200: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급하고 표시 쿠키(1.8)의 만료를 함께 갱신한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. - 만료·무효, 또는 회전 전 Refresh 재사용: 401. 오류 응답은 봉투를 따른다(1.5). 클라이언트는 재로그인으로 유도한다. 회전 발급이므로 재발급 요청은 **동시에 하나만** 보낸다. 401이 여러 건 동시에 발생해도 재발급은 한 번만 호출하고 나머지 요청은 그 결과를 기다린다. @@ -296,32 +316,10 @@ POST /api/core/v1/auth/refresh POST /api/core/v1/auth/logout ``` -- 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키를 만료시킨다. +- 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키와 표시 쿠키(1.8)를 모두 만료시킨다. - 204. -## 3.5 세션 확인 - -```http -GET /api/core/v1/auth/session -``` - -앱 시작 시 로그인 여부를 판단하기 위해 1회 호출한다. 인증 쿠키는 `HttpOnly`라 클라이언트가 직접 읽을 수 없으므로 이 Endpoint가 유일한 판단 근거다. - -- 200: 로그인 상태다. - -```json -{ - "success": true, - "data": { - "authenticated": true - } -} -``` - -- 401: 로그인 상태가 아니다. 보호 Endpoint이므로 Access가 만료됐으면 평소와 같이 재발급(3.3)을 시도하고, 그것도 실패하면 로그인 화면으로 유도한다. -- 집계나 조인이 없는 가벼운 호출이다. 로그인 판단에 `GET /me/summary`(3.6)를 사용하지 않는다. - -## 3.6 마이페이지 요약 +## 3.5 마이페이지 요약 ```http GET /api/core/v1/me/summary @@ -347,7 +345,7 @@ GET /api/core/v1/me/summary - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. - `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1). -## 3.7 회원 탈퇴 +## 3.6 회원 탈퇴 ```http DELETE /api/core/v1/me @@ -363,7 +361,7 @@ DELETE /api/core/v1/me | `member`, `social_account` | 소프트 삭제 | | `record`, `context`, `collection`, `collection_record`, 관련 `follow` | 소프트 삭제 | | `social_account`의 `provider_user_id`, `email` | **마스킹**(개인정보 파기 대상) | -| Refresh Token | 무효화하고 인증 쿠키를 만료시킨다 | +| Refresh Token | 무효화하고 인증 쿠키와 표시 쿠키(1.8)를 만료시킨다 | | `place` | 공용 데이터이므로 유지한다 | - 탈퇴한 사용자의 Shelf와 Collection은 다른 사용자의 Library·Feed에서 즉시 제외한다. diff --git "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" index 31bb92a..c339d2a 100644 --- "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" +++ "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" @@ -15,7 +15,7 @@ | Refresh | Redis 저장, **회전 발급**(재발급 시 이전 토큰 무효화) | | CSRF | `XSRF-TOKEN` 쿠키 → `X-XSRF-TOKEN` 헤더 | | 배포 오리진 | 프론트와 API가 **같은 오리진**. CORS 불필요 | -| 로그인 상태 확인 | `GET /auth/session` (200 / 401) | +| 로그인 상태 확인 | `logged_in` 표시 쿠키 (**UI 힌트 전용**, 인가 판단 금지) | | 콜백 복귀 경로 | `/auth/callback` (실패 시 `?error=`) | | 로그인 수단 | Google · Kakao · Naver 소셜 로그인만 | | 가입 시점 | 소셜 인증 성공 시점에 즉시 회원 생성 | @@ -110,7 +110,6 @@ axios.defaults.withCredentials = true; // fetch면 credentials: 'include' | 로그인 시작·콜백 | `302` + `Set-Cookie` | 없음 | | 재발급 성공 | `200` + `Set-Cookie`, 본문 없음 | 없음 | | 로그아웃 | `204` | 없음 | -| 세션 확인 | `200` + `{ authenticated: true }` | **있음** | | 위 Endpoint의 **오류** | `401` 등 | **있음** (1.5) | 즉 인증 흐름에서 `success` 필드를 보고 분기할 일이 없습니다. **HTTP 상태 코드로 판단**하세요. 오류가 났을 때만 봉투 안의 `error.code`를 봅니다. @@ -166,15 +165,24 @@ window.location.href = '/api/core/v1/auth/google/login'; ### 5.2 앱 시작 +인증 쿠키는 `HttpOnly`라 읽을 수 없습니다. 대신 서버가 **`logged_in` 표시 쿠키**를 함께 내려주므로, 네트워크 호출 없이 첫 화면을 결정할 수 있습니다. + ``` -GET /api/core/v1/auth/session - ├ 200 → 로그인 상태. 메인 화면 - └ 401 → 재발급(4.4) 시도 - ├ 성공 → 재호출 후 메인 화면 - └ 실패 → 로그인 화면 +document.cookie 에 logged_in 있음? + ├ 없음 → 로그인 화면 + └ 있음 → 메인 화면을 렌더링하고 데이터 요청 + └ 401이 오면 → 재발급(4.4) 시도 + ├ 성공 → 재시도 + └ 실패 → 표시 쿠키 정리 후 로그인 화면 ``` -`HttpOnly` 쿠키는 읽을 수 없으므로 이 호출이 로그인 여부의 유일한 판단 근거입니다. 집계가 없는 가벼운 Endpoint라 앱 시작 시 1회 호출해도 부담이 없습니다. +> **이 쿠키는 UI 힌트입니다. 인가 판단에 쓰지 마세요.** +> +> 실제 인가는 서버가 매 요청 인증 쿠키로 검증합니다. 표시 쿠키가 남아 있어도 세션은 이미 무효일 수 있습니다(Refresh 만료, 다른 기기에서 로그아웃 등). 그래서 위 흐름은 "쿠키가 있으면 로그인된 것으로 **간주하고 그려본 뒤**, 401이 오면 정정"하는 구조입니다. +> +> 보호 화면을 미리 그리는 근거로 쓰는 것은 무방합니다. **권한이 있다고 판단하는 근거로 쓰면 안 됩니다.** 민감한 데이터는 어차피 서버가 401로 막습니다. + +이 방식을 택한 이유는 앱 시작마다 확인 요청을 한 번 더 보내지 않아도 되고, 첫 화면에서 로딩 깜빡임이 생기지 않기 때문입니다. ### 5.3 로그아웃 From 43ae14e35f43d69da87aaa56ec49d65eb5e37142 Mon Sep 17 00:00:00 2001 From: Hong Seokho Date: Mon, 27 Jul 2026 15:13:28 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20Refresh=20=EC=BF=A0=ED=82=A4=20Path=20?= =?UTF-8?q?=ED=99=95=EB=8C=80,=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20204,=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=C2=B7=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EB=AC=B4=ED=9A=A8=ED=99=94=20=EA=B7=BC=EA=B1=B0=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #12 리뷰(minyongP) 반영. Important — Refresh 쿠키 Path가 로그아웃·탈퇴를 막던 문제: - Path를 /api/core/v1/auth/refresh → /api/core/v1/auth 로 확대. 로그아웃이 Refresh 쿠키 범위에 들어와 대상 토큰을 특정할 수 있다. 일반 API(/records 등)에는 여전히 실리지 않는다 - §3.4 로그아웃 — Refresh 쿠키로 해당 세션만 무효화. Access가 만료돼도 동작하며, Refresh가 없거나 무효해도 204(이미 로그아웃된 상태를 오류로 취급하지 않는다) - §3.6 탈퇴 — /me는 Refresh 쿠키 Path 밖이므로 Access로 회원을 식별해 해당 회원의 Refresh를 전부 무효화. 모든 기기 로그아웃이 의도된 동작 Minor: - §3.3 재발급 200 → 204. §1.6의 "본문 없는 성공은 204" 규정과 정합 - §3.2 Set-Cookie 예시에 HttpOnly·Secure·SameSite 속성을 실제로 표기 (참조만 두면 예시를 복붙할 때 놓친다) CSRF 403·401을 Security 필터가 직접 쓰는 건 문서 결함이 아니라 구현 의무이므로(back error-handling.md에 명기됨) 문서는 그대로 둔다. 콜백 경로 /auth/callback은 프론트 확정 회신을 받아 변경 없음. Co-Authored-By: Claude Opus 5 (1M context) --- "static/08_API_\353\252\205\354\204\270.md" | 22 +++++++++++++------ ...0\354\246\235_\354\204\244\352\263\204.md" | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git "a/static/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index af96844..27c82dd 100644 --- "a/static/08_API_\353\252\205\354\204\270.md" +++ "b/static/08_API_\353\252\205\354\204\270.md" @@ -22,7 +22,7 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - Refresh Token은 **Redis**에 저장한다(로그아웃 무효화·회전 발급 관리, TTL 자동 만료). - 토큰은 **`HttpOnly` + `Secure` + `SameSite=Lax` 쿠키**로 발급한다. 응답 본문에 토큰을 담지 않으며 클라이언트 스크립트는 토큰을 읽을 수 없다. - 클라이언트는 요청에 자격증명을 포함시키기만 한다(`credentials: include` / `withCredentials`). 인증 헤더를 직접 구성하지 않는다. -- Refresh 쿠키는 `Path`를 재발급 경로로 제한해 일반 요청에 실리지 않게 한다. +- Refresh 쿠키는 `Path=/api/core/v1/auth`로 제한해 일반 API 요청(`/records` 등)에 실리지 않게 한다. 재발급과 로그아웃이 모두 이 범위에 들어간다. - 프론트엔드와 API는 같은 오리진에서 서비스한다. 따라서 `SameSite=None`과 CORS 자격증명 설정이 필요하지 않다. - 인증 쿠키와 별개로, 클라이언트가 로그인 여부를 판단할 수 있도록 **표시용 쿠키**를 함께 발급한다(1.8). - Access 만료(401) 시 `POST /auth/refresh`로 재발급한다. @@ -280,12 +280,12 @@ GET /api/core/v1/auth/{provider}/callback?code={code}&state={state} ```http HTTP/1.1 302 Found Location: /auth/callback -Set-Cookie: accessToken=…; Path=/api/core/v1 -Set-Cookie: refreshToken=…; Path=/api/core/v1/auth/refresh -Set-Cookie: logged_in=1; Path=/ +Set-Cookie: accessToken=…; HttpOnly; Secure; SameSite=Lax; Path=/api/core/v1 +Set-Cookie: refreshToken=…; HttpOnly; Secure; SameSite=Lax; Path=/api/core/v1/auth +Set-Cookie: logged_in=1; Secure; SameSite=Lax; Path=/ ``` -인증 쿠키 속성은 1.1, 표시 쿠키는 1.8을 따른다. +`logged_in`만 `HttpOnly`가 아니다(1.8). 나머지 속성 근거는 1.1에 있다. 복귀 경로는 성공·실패 모두 `/auth/callback` 하나이며, 실패 시에만 `error` query가 붙는다. @@ -305,7 +305,7 @@ POST /api/core/v1/auth/refresh 요청 본문이 없다. Refresh 쿠키로 식별한다. -- 200: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급하고 표시 쿠키(1.8)의 만료를 함께 갱신한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. +- **204**: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급하고 표시 쿠키(1.8)의 만료를 함께 갱신한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. - 만료·무효, 또는 회전 전 Refresh 재사용: 401. 오류 응답은 봉투를 따른다(1.5). 클라이언트는 재로그인으로 유도한다. 회전 발급이므로 재발급 요청은 **동시에 하나만** 보낸다. 401이 여러 건 동시에 발생해도 재발급은 한 번만 호출하고 나머지 요청은 그 결과를 기다린다. @@ -319,6 +319,12 @@ POST /api/core/v1/auth/logout - 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키와 표시 쿠키(1.8)를 모두 만료시킨다. - 204. +무효화 대상은 **Refresh 쿠키로 식별한다.** 이 경로는 Refresh 쿠키의 `Path` 범위(`/api/core/v1/auth`) 안에 있으므로 쿠키가 함께 전송된다. + +- Access가 이미 만료됐어도 로그아웃은 동작한다. Refresh 쿠키만으로 대상을 특정할 수 있기 때문이다. +- 해당 세션 하나만 무효화한다. 다른 기기의 로그인은 유지된다. +- Refresh 쿠키가 없거나 이미 무효한 경우에도 **204**를 반환한다. 서버에 지울 것이 없을 뿐이고, 쿠키 정리는 그대로 수행한다. 이미 로그아웃된 상태를 오류로 취급하지 않는다. + ## 3.5 마이페이지 요약 ```http @@ -361,9 +367,11 @@ DELETE /api/core/v1/me | `member`, `social_account` | 소프트 삭제 | | `record`, `context`, `collection`, `collection_record`, 관련 `follow` | 소프트 삭제 | | `social_account`의 `provider_user_id`, `email` | **마스킹**(개인정보 파기 대상) | -| Refresh Token | 무효화하고 인증 쿠키와 표시 쿠키(1.8)를 만료시킨다 | +| Refresh Token | 해당 회원의 **모든** Refresh를 무효화하고 인증 쿠키와 표시 쿠키(1.8)를 만료시킨다 | | `place` | 공용 데이터이므로 유지한다 | +이 경로는 Refresh 쿠키의 `Path` 범위 밖이라 Refresh 쿠키가 전송되지 않는다. 따라서 **Access 쿠키로 회원을 식별하고 그 회원의 Refresh를 전부 무효화한다.** 탈퇴는 모든 기기에서 즉시 로그아웃되어야 하므로 전체 무효화가 의도된 동작이다. Access가 만료된 상태라면 인증 실패(401)이므로, 클라이언트는 재발급(3.3) 후 다시 요청한다. + - 탈퇴한 사용자의 Shelf와 Collection은 다른 사용자의 Library·Feed에서 즉시 제외한다. - 활성 `social_account`가 사라지므로, 같은 소셜 계정으로 다시 로그인하면 **신규 회원으로 가입**된다(3.2). 과거 데이터는 복구되지 않는다. diff --git "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" index c339d2a..0a96e48 100644 --- "a/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" +++ "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" @@ -108,7 +108,7 @@ axios.defaults.withCredentials = true; // fetch면 credentials: 'include' | Endpoint | 응답 | 봉투 | |---|---|---| | 로그인 시작·콜백 | `302` + `Set-Cookie` | 없음 | -| 재발급 성공 | `200` + `Set-Cookie`, 본문 없음 | 없음 | +| 재발급 성공 | `204` + `Set-Cookie` | 없음 | | 로그아웃 | `204` | 없음 | | 위 Endpoint의 **오류** | `401` 등 | **있음** (1.5) | @@ -121,7 +121,7 @@ BFF와 리소스 서버가 한 몸이므로, 서버가 요청 처리 도중 자 ``` API 호출 → 401 → POST /api/core/v1/auth/refresh (본문 없음, 쿠키로 동작) - ├ 200 → 새 쿠키 자동 저장 → 원래 요청 재시도 + ├ 204 → 새 쿠키 자동 저장 → 원래 요청 재시도 └ 401 → 로그인 화면으로 ```