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/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/08_API_\353\252\205\354\204\270.md" "b/static/08_API_\353\252\205\354\204\270.md" index aef8df4..27c82dd 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,10 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 문서를, 화면 흐름은 유저플로우 문서를 따릅니다. -- 기본 경로: `/api/core/` +- 서비스 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 @@ -17,10 +20,14 @@ MVP REST API 명세입니다. 데이터 구조는 데이터 모델 및 무결성 - 방식: **JWT (확정)**. Access Token + Refresh Token. - 만료: Access **30분**, Refresh **7일**. - Refresh Token은 **Redis**에 저장한다(로그아웃 무효화·회전 발급 관리, TTL 자동 만료). -- 보호 Endpoint는 `Authorization: Bearer {accessToken}` 헤더가 필요하다. +- 토큰은 **`HttpOnly` + `Secure` + `SameSite=Lax` 쿠키**로 발급한다. 응답 본문에 토큰을 담지 않으며 클라이언트 스크립트는 토큰을 읽을 수 없다. +- 클라이언트는 요청에 자격증명을 포함시키기만 한다(`credentials: include` / `withCredentials`). 인증 헤더를 직접 구성하지 않는다. +- Refresh 쿠키는 `Path=/api/core/v1/auth`로 제한해 일반 API 요청(`/records` 등)에 실리지 않게 한다. 재발급과 로그아웃이 모두 이 범위에 들어간다. +- 프론트엔드와 API는 같은 오리진에서 서비스한다. 따라서 `SameSite=None`과 CORS 자격증명 설정이 필요하지 않다. +- 인증 쿠키와 별개로, 클라이언트가 로그인 여부를 판단할 수 있도록 **표시용 쿠키**를 함께 발급한다(1.8). - Access 만료(401) 시 `POST /auth/refresh`로 재발급한다. -- 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 토큰으로 식별한다. -- 내부 사용자 ID는 공개 응답에 포함하지 않는다(로그인·가입 응답에서 본인 `memberId`를 받는 것만 예외). +- 개인 API에서 사용자 ID를 요청 Query나 Body로 받지 않는다. 서버가 쿠키로 식별한다. +- 내부 사용자 ID는 응답에 포함하지 않는다. 클라이언트는 자신의 `memberId`를 알 필요가 없다. ## 1.2 권한 실패 @@ -98,6 +105,7 @@ Record·Context 생성 및 수정 응답은 Keyword·Embedding 생성을 기다 | `204` | 응답 본문 없는 성공 | | `400` | 형식 또는 입력값 오류 | | `401` | 인증 필요 | +| `403` | CSRF 토큰 누락·불일치 (자원 접근 권한 실패는 1.2에 따라 `404`) | | `404` | 리소스 없음 또는 접근 권한 없음 | | `409` | 상태 충돌 (연쇄 삭제 확인 필요 등) | | `422` | 도메인 규칙 위반 | @@ -121,19 +129,47 @@ 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`을 비롯한 조회 요청은 해당하지 않는다. + +## 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 전체 목록 +아래 표의 Endpoint는 모두 기본 경로 `/api/core/v1` 뒤에 붙는 상대 경로다. 예를 들어 `/auth/logout`의 전체 경로는 `/api/core/v1/auth/logout`이다. 3장 이후의 상세에서는 전체 경로로 표기한다. + ## 2.1 인증·계정 | 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 +267,65 @@ naver GET /api/core/v1/auth/{provider}/callback?code={code}&state={state} ``` -공급자 인증 후 분기한다. - -기존 회원 (활성 `social_account` 존재): - -```json -{ "success": true, "data": { "status": "LOGIN", "memberId": 1201, "accessToken": "…", "refreshToken": "…" } } -``` - -신규 (가입 미완료): +공급자 인증 후 다음을 수행한다. -```json -{ "success": true, "data": { "status": "SIGNUP_REQUIRED", "signupToken": "…" } } -``` +- 활성 `social_account`가 있으면 그 회원으로 로그인한다. +- 없으면 이 시점에 `member`와 `social_account`를 생성한다. 소셜 인증 성공이 곧 가입 완료다. +- 두 경우 모두 인증 쿠키를 발급하고 클라이언트 애플리케이션으로 리다이렉트한다. -- 신규는 이 시점에 `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: /auth/callback +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=/ ``` -```json -{ - "agreed": true -} -``` +`logged_in`만 `HttpOnly`가 아니다(1.8). 나머지 속성 근거는 1.1에 있다. -- 필수 동의가 `true`가 아니면 400. -- 동작: `member` + `social_account` 생성(가입 확정) 후 토큰 발급. +복귀 경로는 성공·실패 모두 `/auth/callback` 하나이며, 실패 시에만 `error` query가 붙는다. -```json -{ "success": true, "data": { "memberId": 1201, "accessToken": "…", "refreshToken": "…" } } +```text +성공: /auth/callback +실패: /auth/callback?error=OAUTH_FAILED ``` -201. +- 복귀 경로는 **서버 설정값**이며 요청 파라미터로 받지 않는다. 임의 URL을 받으면 open redirect 취약점이 된다. +- 로그인 이전 화면으로 되돌아가는 처리는 클라이언트가 담당한다(로그인 시작 전 경로를 `sessionStorage` 등에 보관). -## 3.4 토큰 재발급 +## 3.3 토큰 재발급 ```http POST /api/core/v1/auth/refresh ``` -```json -{ "refreshToken": "…" } -``` +요청 본문이 없다. Refresh 쿠키로 식별한다. -- 200: `{ "success": true, "data": { "accessToken": "…", "refreshToken": "…" } }` (Refresh도 회전 발급) -- 만료·무효 Refresh: 401 → 프론트는 재로그인으로 유도. +- **204**: 새 Access·Refresh 쿠키를 `Set-Cookie`로 발급하고 표시 쿠키(1.8)의 만료를 함께 갱신한다(Refresh도 회전 발급). 본문이 없으므로 봉투(1.6)가 적용되지 않는다. +- 만료·무효, 또는 회전 전 Refresh 재사용: 401. 오류 응답은 봉투를 따른다(1.5). 클라이언트는 재로그인으로 유도한다. -## 3.5 로그아웃 +회전 발급이므로 재발급 요청은 **동시에 하나만** 보낸다. 401이 여러 건 동시에 발생해도 재발급은 한 번만 호출하고 나머지 요청은 그 결과를 기다린다. + +## 3.4 로그아웃 ```http POST /api/core/v1/auth/logout ``` -- 동작: Refresh Token 무효화. Access는 만료로 자연 소멸. +- 동작: Refresh Token을 무효화하고 Access·Refresh 쿠키와 표시 쿠키(1.8)를 모두 만료시킨다. - 204. -## 3.6 마이페이지 요약 +무효화 대상은 **Refresh 쿠키로 식별한다.** 이 경로는 Refresh 쿠키의 `Path` 범위(`/api/core/v1/auth`) 안에 있으므로 쿠키가 함께 전송된다. + +- Access가 이미 만료됐어도 로그아웃은 동작한다. Refresh 쿠키만으로 대상을 특정할 수 있기 때문이다. +- 해당 세션 하나만 무효화한다. 다른 기기의 로그인은 유지된다. +- Refresh 쿠키가 없거나 이미 무효한 경우에도 **204**를 반환한다. 서버에 지울 것이 없을 뿐이고, 쿠키 정리는 그대로 수행한다. 이미 로그아웃된 상태를 오류로 취급하지 않는다. + +## 3.5 마이페이지 요약 ```http GET /api/core/v1/me/summary @@ -316,7 +349,31 @@ GET /api/core/v1/me/summary - 카운트는 모두 활성 데이터 기준 집계다. - 팔로워·팔로잉 목록은 제공하지 않는다. 수치는 본인만 볼 수 있다. -- `memberId`는 로그인 응답(3.2 LOGIN, 3.3)에서 이미 전달되므로 여기서는 반환하지 않는다. +- `memberId`는 반환하지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 클라이언트가 자신의 내부 ID를 알 필요가 없다(1.1). + +## 3.6 회원 탈퇴 + +```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 | 해당 회원의 **모든** 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/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 분리 저장 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..0a96e48 --- /dev/null +++ "b/static/11_\354\235\270\354\246\235_\354\204\244\352\263\204.md" @@ -0,0 +1,250 @@ +# PinLog 인증 설계 + +인증 방식의 결정과 그 근거, 그리고 클라이언트가 지켜야 할 계약입니다. Endpoint의 요청·응답 형태는 [API 명세](08_API_명세.md) 1장과 3장을 따릅니다. 이 문서는 **왜 그렇게 정했는가**와 **클라이언트가 무엇을 해야 하는가**를 다룹니다. + +--- + +## 1. 결론 + +| 항목 | 결정 | +|---|---| +| 아키텍처 | BFF와 리소스 서버를 **한 서버**로 운영 | +| 인증 방식 | JWT. Access 30분 / Refresh 7일 | +| 토큰 전달 | **`HttpOnly` + `Secure` + `SameSite=Lax` 쿠키**. 응답 본문에 토큰을 담지 않음 | +| 토큰 보관 | 클라이언트는 토큰을 보관하지도, 읽지도 않음 | +| Refresh | Redis 저장, **회전 발급**(재발급 시 이전 토큰 무효화) | +| CSRF | `XSRF-TOKEN` 쿠키 → `X-XSRF-TOKEN` 헤더 | +| 배포 오리진 | 프론트와 API가 **같은 오리진**. CORS 불필요 | +| 로그인 상태 확인 | `logged_in` 표시 쿠키 (**UI 힌트 전용**, 인가 판단 금지) | +| 콜백 복귀 경로 | `/auth/callback` (실패 시 `?error=`) | +| 로그인 수단 | 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` | 없음 | +| 재발급 성공 | `204` + `Set-Cookie` | 없음 | +| 로그아웃 | `204` | 없음 | +| 위 Endpoint의 **오류** | `401` 등 | **있음** (1.5) | + +즉 인증 흐름에서 `success` 필드를 보고 분기할 일이 없습니다. **HTTP 상태 코드로 판단**하세요. 오류가 났을 때만 봉투 안의 `error.code`를 봅니다. + +### 4.4 401 처리 — 재발급은 클라이언트가 호출합니다 + +BFF와 리소스 서버가 한 몸이므로, 서버가 요청 처리 도중 자기 토큰을 갱신하지 않습니다. + +``` +API 호출 → 401 + → POST /api/core/v1/auth/refresh (본문 없음, 쿠키로 동작) + ├ 204 → 새 쿠키 자동 저장 → 원래 요청 재시도 + └ 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 앱 시작 + +인증 쿠키는 `HttpOnly`라 읽을 수 없습니다. 대신 서버가 **`logged_in` 표시 쿠키**를 함께 내려주므로, 네트워크 호출 없이 첫 화면을 결정할 수 있습니다. + +``` +document.cookie 에 logged_in 있음? + ├ 없음 → 로그인 화면 + └ 있음 → 메인 화면을 렌더링하고 데이터 요청 + └ 401이 오면 → 재발급(4.4) 시도 + ├ 성공 → 재시도 + └ 실패 → 표시 쿠키 정리 후 로그인 화면 +``` + +> **이 쿠키는 UI 힌트입니다. 인가 판단에 쓰지 마세요.** +> +> 실제 인가는 서버가 매 요청 인증 쿠키로 검증합니다. 표시 쿠키가 남아 있어도 세션은 이미 무효일 수 있습니다(Refresh 만료, 다른 기기에서 로그아웃 등). 그래서 위 흐름은 "쿠키가 있으면 로그인된 것으로 **간주하고 그려본 뒤**, 401이 오면 정정"하는 구조입니다. +> +> 보호 화면을 미리 그리는 근거로 쓰는 것은 무방합니다. **권한이 있다고 판단하는 근거로 쓰면 안 됩니다.** 민감한 데이터는 어차피 서버가 401로 막습니다. + +이 방식을 택한 이유는 앱 시작마다 확인 요청을 한 번 더 보내지 않아도 되고, 첫 화면에서 로딩 깜빡임이 생기지 않기 때문입니다. + +### 5.3 로그아웃 + +``` +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. 배포 오리진과 쿠키 + +### 7.1 운영 — 같은 오리진 + +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 +``` + +- 포트가 달라도 **same-site**입니다(`SameSite` 판정에 포트가 들어가지 않음). `Lax` 쿠키가 정상 전송됩니다 +- 다만 origin은 다르므로 **CORS 설정이 로컬에서만 필요**합니다. 운영에는 해당하지 않습니다 +- 최신 브라우저는 `localhost`를 신뢰 컨텍스트로 취급해 `http`에서도 `Secure` 쿠키가 동작합니다. `127.0.0.1`이나 LAN IP 대신 `localhost` 이름으로 접속하세요 + +--- + +## 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 판정에 포트가 포함되지 않음