From fe355f3943c75131613c0551296966a489d47ae7 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 11:06:01 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EA=B3=84=EC=95=BD=201=EC=A0=88?= =?UTF-8?q?=EC=97=90=20=EC=95=B1=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(=EC=95=B1?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=ED=86=A0=ED=81=B0=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?)=EC=9D=84=20=EC=A0=81=EA=B3=A0=205=EC=A0=88=EC=9D=84=20?= =?UTF-8?q?=EC=95=B1=EA=B3=BC=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-05 사용자가 정한 앱 전용 토큰 경로다(웹의 쿠키를 앱에 쓰지 않는다). 서버는 아직 없다 — 앱은 이 절대로 만들고 가짜 서버로 시험하며, 서버는 다음 차례에 이 절대로 만든다. - POST /api/auth/app/login · refresh · logout — 토큰은 본문으로, Bearer 는 앱 표시(cli: app) 토큰만 · 쿠키는 웹 토큰만. 세션 규칙 · 실패 카운터 · 2단계 코드 가드 · 제한된 세션은 웹과 같다 - 유예 응답(tokens: null)은 세션 끝 — 액세스 토큰이 남았으면 Bearer 로 조용히 logout, logout 본문의 refreshToken 은 선택 - 앱이 할 일: 토큰 보관 · 갱신 한 번에 하나 · AUTH_010 다시 확인 · 늦은 응답 버리기 - 5절(계정 보안): 앱 세션도 같은 목록에, 앱은 sid 로 지금 세션을 안다, 로그인 기록 method app, 기기 이름 "ARA SYSTEM 앱" Co-Authored-By: Claude Opus 5.5 --- docs/mail/api-contract.md | 46 +++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/mail/api-contract.md b/docs/mail/api-contract.md index 8a3681a..7e0169a 100644 --- a/docs/mail/api-contract.md +++ b/docs/mail/api-contract.md @@ -10,8 +10,8 @@ - 날짜·시각은 ISO 8601 UTC 문자열, 목록은 `{ items, pageInfo }` - 이 단계에서 쓰는 에러 코드: `AUTH_001` `AUTH_002`(클라이언트 전용) `AUTH_003` `AUTH_004` `AUTH_005` `VAL_001` `RES_001` `REQ_001` `FILE_001` `FILE_002` `MAIL_001` `MAIL_002` `MAIL_003` `MAIL_004` `MAIL_005` `MAIL_006` `MAIL_007` `MAIL_008` `MAIL_009` `MAIL_010` `MAIL_011` `MAIL_012` `MAIL_013` `MAIL_014` `MAIL_015` `CONTACT_001` `CONTACT_002` `SYS_001` (11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를, 13단계 — 6절 "전환 준비"가 `AUTH_010` 을 더한다) -- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다. - 모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다. +- **적용 범위: 웹(브라우저)은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.** + 모바일 앱(Flutter)은 1절 끝 "앱 로그인 (앱 전용 토큰 경로)"를 쓴다(2026-11-05 — 세션 규칙은 웹과 같고 토큰을 나르는 길만 다르다). - **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고, 개발은 vite 프록시가 넘긴다. 그래서 CORS 가 필요 없고 CSP `connect-src 'self'` 로 충분하다. - **요청 대상에 날 `\` · `#` · 제어 문자 · 공백 · 0x80 이상 바이트가 있으면 라우팅 전에 400 `VAL_001` 이다**(12단계 — 백엔드 `request-target` 미들웨어, 앞단 nginx 도 막는다). @@ -26,7 +26,7 @@ --- -## 1. 인증 (쿠키 기반 — 토큰은 응답 본문에 없다) +## 1. 인증 (웹은 쿠키 — 토큰은 응답 본문에 없다 · 앱은 절 끝 "앱 로그인") | 쿠키 | 내용 | 속성 | | --- | --- | --- | @@ -131,6 +131,36 @@ 성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다. → 백엔드는 **액세스 쿠키가 없거나 만료돼도** 리프레시 쿠키만으로 `/auth/refresh` 가 동작해야 한다. +### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · **서버 미구현**) + +> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)은 이 절대로 만들고 가짜 서버로 시험한다 — 서버는 다음 차례에 이 절대로 만든다. +> **세션 규칙은 웹과 같다**: 1회용 회전 · 해시만 저장 · 재사용 감지 · 30초 유예 · 끊은 세션은 `sid` 로 바로 막기 · 절대 30일 · 미사용 14일 · 로그인 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 제한된 세션(6절). 다른 것은 토큰을 나르는 길뿐이다. + +| 경로 | 본문 | 응답 | +| --- | --- | --- | +| `POST /api/auth/app/login` | 웹 login 과 같다(`email` · `password` · `otpCode?`) | 웹 login 응답과 같은 평평한 모양(`status` · `email` · `name` · `restriction` · `twoFactorDeadline`) + `tokens`. `signedIn`(웹이 쿠키를 심는 바로 그 경우)에만 `tokens` 가 차고, 그 밖(`twoFactorRequired` 등)은 `tokens: null` | +| `POST /api/auth/app/refresh` | `{ refreshToken }` | 웹 refresh 와 같은 칸(`email` · `name` · `restriction` · `twoFactorDeadline` — 6절: 늘 싣는다) + `tokens`. 30초 유예 안의 다시 쓰기는 웹처럼 새 토큰 없이 200(`tokens: null`). 끝난 세션 · 틀린 토큰은 401 `AUTH_001` | +| `POST /api/auth/app/logout` | `{ refreshToken? }`(보안 저장소를 읽지 못했으면 빈 본문 `{}`) + 머리 `Authorization: Bearer`(있으면) | 웹 logout 과 같다 — 리프레시 토큰으로 세션을 찾고, 없거나 틀리면 액세스 토큰의 `sid` 로 끝낸다. 어느 쪽도 맞지 않아도 성공으로 답한다 | + +- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 토큰이 든 응답은 `Cache-Control: no-store`. +- 보호 API 는 `Authorization: Bearer `. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게). `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다. +- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게). 출처 검사(OriginGuard)는 앱(`Origin` · `Sec-Fetch-Site` 없음)을 지금 규칙 그대로 통과시킨다. +- **"지금 세션"**: 앱 요청은 리프레시 쿠키가 없으므로 액세스 토큰의 `sid` 로 안다 — `PUT /api/auth/password`(이 세션만 남기기) · `/api/auth/sessions`(isCurrent · 다른 곳 모두 끊기) · 앱 logout. +- 세션 표에 `client`(`web` | `app`) 칸을 둔다. 앱은 User-Agent `ARASYSTEM-App/<앱 판> (Android <판>; <기종>)` 을 보내고, 서버는 로그인된 기기 · 로그인 기록에 "ARA SYSTEM 앱 · Android" 꼴로 보이고 로그인 기록 `method` 는 `app` 이다. + +**앱이 할 일** +- 리프레시 토큰은 `flutter_secure_storage`(키 `kRefreshTokenKey`)에만, 액세스 토큰은 메모리에만 둔다. 새 리프레시 토큰은 받자마자 저장한다 — 회전 직후 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤 재사용으로 세션을 끊는다(알려진 한계). + iOS Keychain 은 앱을 지워도 남는다 — 첫 실행 표시(일반 저장소)가 없으면 남은 토큰을 지운다. Android 는 백업을 끈다(토큰 · 첫 실행 표시가 다른 기기로 복원되지 않게). +- 보호 API 의 401 이면 갱신을 **한 번에 하나**(동시 401 을 한 갱신에 묶는다) → 원래 요청 한 번 다시. 앱 login · refresh · logout 은 갱신하지 않는다. 갱신의 401 이면 토큰을 지우고 `AUTH_002`(클라이언트 전용) + 로그인 화면, 5xx · 연결 끊김은 세션을 그대로 둔다. + `accessExpiresAt` 30초 전에는 미리 갱신한다. +- **유예 응답(`tokens: null`)은 세션 끝으로 다룬다** — 앱은 갱신을 한 번에 하나만 하므로 그 응답은 "앞 갱신의 응답을 잃었다"는 뜻이고 이어 쓸 리프레시 토큰이 없다(서버는 해시만 두어 같은 토큰을 다시 줄 수 없다). + 메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다 — 서버의 그 세션이 "로그인된 기기"에 14일 남지 않게(`sid` 로 끝낸다). +- `AUTH_010` 은 알림 없이 me 를 다시 받아 그 단계로 — me 에 제한이 없으면 웹처럼 갱신한 뒤 원래 요청을 한 번 다시 보낸다. +- 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · me)은 쓰지 않는다 — 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 을 부른다(웹의 "로그아웃 뒤 늦은 갱신"과 같은 결). +- 코드 단계 · 제한 단계에서 다시 쓸 비밀번호는 화면 메모리에만 10분 — 성공 · 다른 계정 · 떠남 · 제한이 풀릴 때 지운다(웹과 같다). + +**이 절 다음(앱 첫 판 안 — 따로 차례)**: 푸시 기기 등록(`POST`·`DELETE /api/push-devices` — 앱 세션에 묶어 logout · 세션 끊기 · 비밀번호 바꾸기 · 퇴사 마무리 때 함께 지운다, 내용은 "새 메일 N통"만), 앱 최소 판 확인(공개 `GET /api/app-config` → `{ minVersion, latestVersion, storeUrl }`). + --- ## 2. 메일 (모두 로그인 필요 — 없으면 401 `AUTH_001`) @@ -2068,7 +2098,7 @@ interface FileUploadCreate { > 2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 근거는 Stalwart 0.16.27 개발 엔진 실험(2026-10-31 — 엔진 TOTP · 앱 비밀번호 · 웹훅 · 자동 차단). > - 엔진이 하는 것: 코드 확인(앞뒤 30초까지), 2단계를 켠 계정의 비밀번호만 하는 IMAP · SMTP 로그인 막기, 앱 비밀번호 만들기(값은 엔진이 정한다), 로그인 이벤트 보내기(웹훅). > - 서버가 하는 것(엔진이 하지 않는 것): 등록 때 첫 코드 확인, 같은 코드 다시 쓰기 막기, 코드 실패 한도, 웹메일 로그인에서 앱 비밀번호 거절, 로그인 기록 저장 · 보존. -> - 웹(브라우저)만이다. 모바일 앱은 앱 착수 때 이 절에 더한다. +> - 화면은 웹이다. 앱은 1절 "앱 로그인"의 로그인 · 코드 · 새 비밀번호 · 2단계 켜기만 하고 계정 설정은 웹에서 한다. 앱의 세션 · 로그인 기록도 이 절의 목록에 함께 보인다(아래 표 · 로그인된 기기 · 로그인 기록의 앱 줄). ### 공유 계약을 바꾸는 곳 (한데 모음) @@ -2089,7 +2119,7 @@ interface FileUploadCreate { | 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) | | 앱 비밀번호 | 엔진 상한(기본 5개 — `x:Authentication.maxAppPasswords`). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 | | 로그인 기록 | 90일 보존(날마다 정리) | -| 로그인된 브라우저 | 끝나지 않은 웹메일 세션 — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일) | +| 로그인된 브라우저 | 끝나지 않은 웹메일 세션과 앱 세션(같은 세션 표 — 1절 "앱 로그인"의 `client`) — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일). 앱 서버를 만들 때 화면 이름도 "로그인된 기기"로 함께 정한다 | ### 로그인 (`POST /api/auth/login`) — 1절에 더하는 것 @@ -2247,12 +2277,14 @@ interface FileUploadCreate { - `ip` · `device` 는 로그인 · 갱신 때 서버가 둔다. - IP 는 `TRUST_PROXY` 기준 클라이언트 IP 다. - 브라우저는 서버가 User-Agent 를 짧은 이름(`Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet` / `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`)으로 바꾼다 — 서버 순수 함수 하나, 모르면 `null`. + 앱 세션(User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)` — 1절 "앱 로그인")은 `ARA SYSTEM 앱` · `Android` 다. - 12단계 전에 연 세션은 둘 다 `null` 이다. - **끊은 세션은 바로 막는다.** 액세스 토큰에 세션 id(`sid`)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401 `AUTH_001` 로 막는다 — DB 를 묻지 않는다. - 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다. - 로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 감지로 세션을 끊을 때도 같다 — 1절의 "끊어도 만료까지 유효"를 바꾼다. - 서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다. 12단계 전에 발급한 토큰(`sid` 없음)은 지금처럼 만료까지 쓰인다. - 리프레시 쿠키가 없거나 남의 것이면 `DELETE /api/auth/sessions` 는 모두 끊는다(비밀번호 바꾸기와 같다). 그때 `isCurrent` 는 모두 false 다. + **앱(Bearer)은 쿠키 대신 액세스 토큰의 `sid` 로 지금 세션을 안다**(1절 "앱 로그인") — 앱이 부른 목록의 `isCurrent` · `DELETE /api/auth/sessions` 는 그 세션을 지금 세션으로 다룬다(남긴다). - 페이지: 기본 20 · 최대 100(`limit`), 위치 커서(`shared/utils/offset-cursor.ts`). ### 로그인 기록 (`GET /api/login-events`) @@ -2271,8 +2303,8 @@ interface FileUploadCreate { | 칸 | 값 | | --- | --- | -| `method` | `webmail` · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기) | -| `isTwoFactor` | 웹메일 성공이 코드까지 거쳤는지 | +| `method` | `webmail` · `app`(앱 로그인 — 1절 "앱 로그인", 서버를 만들 때 더한다) · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기). 웹은 모르는 값을 그대로 보이므로 `app` 을 더할 때 화면의 이름 글도 함께 더한다 | +| `isTwoFactor` | 웹메일 · 앱 성공이 코드까지 거쳤는지 | | `failureReason` | `wrongPassword`(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · `wrongCode`(코드 틀림 · 다시 쓴 코드) · `appPasswordRequired`(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 `auth.mfa-required`) | | `appPassword` | 메일 프로그램이 앱 비밀번호로 들어온 경우. `name` 은 그때의 이름이다(지운 뒤에도 남는다) |