generated from ttipo/project-base
docs: 계약 1 · 5 · 6절과 디자인 README 를 앱 로그인 서버에 맞춘다
- 계약 1절 "앱 로그인": 서버 구현, 갱신 · 로그아웃의 400 / 401 경계(빈 · 너무 긴 토큰), private, no-store, Bearer 는 cli 와 sid 가 있는 앱 토큰만, 웹 · 앱 로그인 요청 수 한 칸, 앱 세션의 갱신 유예는 세션 끝(서버도 끊는다), 앱 이름은 진짜 클라이언트로만, 알려진 한계 - 5절: "로그인된 기기"(세션 목록 client 칸 · 앱 기기 이름 규칙), 로그인 기록 method app · device 가 앱 기록에도 - 6절: 운영 도구 현황의 마지막 로그인(웹메일 · 앱), 재설정 · 이전 마치기의 세션 끊기가 앱도 - 디자인 README: 12단계 로그인된 기기 · 최근 로그인 · 비밀번호 화면 글, 13단계 직원 알림 메일의 로그아웃 줄, 2단계 켜기에서 다른 곳이 먼저 켠 경우(서버가 이 세션을 끊는다 — 계약 6절) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+30
-23
@@ -78,7 +78,7 @@
|
||||
두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다.
|
||||
- 세션은 로그인 후 **최대 30일**(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다.
|
||||
- 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다.
|
||||
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 브라우저").
|
||||
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 기기").
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
@@ -131,22 +131,24 @@
|
||||
성공하면 복원 완료, 실패하면 로그아웃 상태로 시작한다.
|
||||
→ 백엔드는 **액세스 쿠키가 없거나 만료돼도** 리프레시 쿠키만으로 `/auth/refresh` 가 동작해야 한다.
|
||||
|
||||
### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · **서버 미구현**)
|
||||
### 앱 로그인 (앱 전용 토큰 경로) — 앱 1단계 (2026-11-05 정함 · 2026-11-06 서버)
|
||||
|
||||
> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)은 이 절대로 만들고 가짜 서버로 시험한다 — 서버는 다음 차례에 이 절대로 만든다.
|
||||
> 2026-11-05 사용자가 앱 전용 토큰 경로를 정했다(웹의 쿠키를 앱에 쓰지 않는다). 앱(`flutter/`)과 서버(`modules/auth` 의 `app-auth.controller.ts`)가 이 절대로 만들었다.
|
||||
> **세션 규칙은 웹과 같다**: 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` 로 끝낸다. 어느 쪽도 맞지 않아도 성공으로 답한다 |
|
||||
| `POST /api/auth/app/refresh` | `{ refreshToken }`(글 — 없거나 글이 아니면 400 `VAL_001`) | 웹 refresh 와 같은 칸(`email` · `name` · `restriction` · `twoFactorDeadline` — 6절: 늘 싣는다) + `tokens`. 30초 유예 안의 다시 쓰기는 웹처럼 새 토큰 없이 200(`tokens: null`) — **앱 세션은 그때 서버가 그 세션을 끝낸다**(아래). 끝난 세션 · 틀린 토큰(빈 글 · 2,048자 넘는 글 포함 — 서명을 보기 전에)은 401 `AUTH_001` |
|
||||
| `POST /api/auth/app/logout` | `{ refreshToken? }`(보안 저장소를 읽지 못했으면 빈 본문 `{}`) + 머리 `Authorization: Bearer`(있으면) | 웹 logout 과 같다 — 리프레시 토큰으로 세션을 찾고, 없거나 틀리면(빈 글 · 2,048자 넘는 글 포함) 액세스 토큰의 `sid` 로 끝낸다(액세스 토큰은 만료됐어도 서명이 맞으면 — 세션을 끝내기만 한다). 어느 쪽도 맞지 않아도 200. 본문 모양이 틀리면(`refreshToken` 이 글이 아님 · 모르는 칸) 400 `VAL_001` |
|
||||
|
||||
- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 토큰이 든 응답은 `Cache-Control: no-store`.
|
||||
- 보호 API 는 `Authorization: Bearer <accessToken>`. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게). `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다.
|
||||
- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게). 출처 검사(OriginGuard)는 앱(`Origin` · `Sec-Fetch-Site` 없음)을 지금 규칙 그대로 통과시킨다.
|
||||
- `tokens = { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }`(시각은 ISO 8601 UTC) — 로그인 · 갱신 응답은 `Cache-Control: private, no-store`(처리기가 낸 오류 응답도 — 가드 · 본문 파서가 먼저 막은 응답(403 출처 · 429 한도 · 400 · 413 · 415)에는 토큰이 없어 이 머리가 없다).
|
||||
- 보호 API 는 `Authorization: Bearer <accessToken>`. **앱 토큰에만 표시 `cli: 'app'`** 를 싣고, Bearer 는 그 표시와 `sid` 가 있는 토큰만 · 쿠키는 표시가 없는(웹) 토큰만 받는다(서로 바꿔 쓰지 못하게 — 리프레시 토큰도, 세션 표의 `client` 도 맞아야 한다. 다르면 401 이고 재사용으로 보지 않는다). `Authorization: Bearer` 가 있으면 그것만 보고 쿠키는 보지 않는다. `GET /api/auth/me` · 6절의 제한 허용 경로도 Bearer 로 그대로 — 제한(`rst`)과 403 `AUTH_010` 도 웹과 같다.
|
||||
- 실패 카운터 · 요청 수 한도 · 2단계 코드 가드 · 앱 비밀번호 모양 거절은 웹 login 과 같은 함수를 지난다(앱 경로가 한도를 비켜 가지 않게 — 로그인 요청 수 한도는 웹 · 앱이 IP 마다 1분 60회 한 칸을 함께 쓴다). 출처 검사(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` 이다.
|
||||
- 세션 표에 `client`(`web` | `app`) 칸을 둔다(마이그레이션 `AddAppSessionClient` — 기본 `web`). 앱은 User-Agent `ARASYSTEM-App/<앱 판> (Android <판>; <기종>)` 을 보내고, 서버는 로그인된 기기 · 로그인 기록에 "ARA SYSTEM 앱 · Android" 꼴로 보이고 로그인 기록 `method` 는 `app` 이다.
|
||||
**앱 이름은 진짜 클라이언트(세션의 `client` · 로그인 기록의 `method`)로만 붙인다** — 웹 세션이 앱 꼴 User-Agent 를 보내도 앱 이름을 받지 않는다(비밀번호를 훔친 사람이 직원 본인의 앱처럼 보이지 않게). 세션 목록 항목에 `client` 칸을 싣는다(5절).
|
||||
- **앱 세션의 갱신 유예는 그 세션을 끝낸다**(2026-11-06 리뷰): 앱은 갱신을 한 번에 하나만 하므로 앱 세션의 유예는 "회전 응답을 잃음" 아니면 "훔친 토큰을 누가 먼저 씀"이다. 서버는 그 세션을 끊고(사유 `app-refresh-grace`) 200 `tokens: null` 로 답한다 — 앱이 다음 실행 직후라 조용한 logout 을 보내지 못해도 남는 세션이 없다. 웹의 유예는 그대로다(탭 여러 개).
|
||||
|
||||
**앱이 할 일**
|
||||
- 리프레시 토큰은 `flutter_secure_storage`(키 `kRefreshTokenKey`)에만, 액세스 토큰은 메모리에만 둔다. 새 리프레시 토큰은 받자마자 저장한다 — 회전 직후 앱이 죽으면 다음 실행의 옛 토큰이 30초 뒤 재사용으로 세션을 끊는다(알려진 한계).
|
||||
@@ -154,7 +156,9 @@
|
||||
- 보호 API 의 401 이면 갱신을 **한 번에 하나**(동시 401 을 한 갱신에 묶는다) → 원래 요청 한 번 다시. 앱 login · refresh · logout 은 갱신하지 않는다. 갱신의 401 이면 토큰을 지우고 `AUTH_002`(클라이언트 전용) + 로그인 화면, 5xx · 연결 끊김은 세션을 그대로 둔다.
|
||||
`accessExpiresAt` 30초 전에는 미리 갱신한다.
|
||||
- **유예 응답(`tokens: null`)은 세션 끝으로 다룬다** — 앱은 갱신을 한 번에 하나만 하므로 그 응답은 "앞 갱신의 응답을 잃었다"는 뜻이고 이어 쓸 리프레시 토큰이 없다(서버는 해시만 두어 같은 토큰을 다시 줄 수 없다).
|
||||
메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다 — 서버의 그 세션이 "로그인된 기기"에 14일 남지 않게(`sid` 로 끝낸다).
|
||||
메모리의 액세스 토큰이 아직 있으면 그것을 Bearer 로 실어 조용히 앱 logout 을 부른다(서버도 그 세션을 이미 끝냈다 — 남는 것이 없게 두 겹).
|
||||
나중에 푸시를 따로 받는 백그라운드 작업이 생겨도 갱신은 앱과 같은 자물쇠를 함께 쓴다 — 유예가 세션 끝이므로 둘이 따로 갱신하면 서로의 세션을 끊는다.
|
||||
- **알려진 한계**: 회전 응답을 잃고 30초가 지난 뒤 같은 토큰을 다시 보내면 재사용으로 세션이 끊긴다(서버 로그에 탈취 의심 warn) — 모바일 망에서는 웹보다 잦을 수 있다.
|
||||
- `AUTH_010` 은 알림 없이 me 를 다시 받아 그 단계로 — me 에 제한이 없으면 웹처럼 갱신한 뒤 원래 요청을 한 번 다시 보낸다.
|
||||
- 로그아웃 · 다른 계정으로 로그인 · 세션 끝 뒤에 도착한 응답(로그인 · 코드 · 갱신 · me)은 쓰지 않는다 — 버린 로그인 결과에 토큰이 있으면 그 토큰으로 조용히 앱 logout 을 부른다(웹의 "로그아웃 뒤 늦은 갱신"과 같은 결).
|
||||
- 코드 단계 · 제한 단계에서 다시 쓸 비밀번호는 화면 메모리에만 10분 — 성공 · 다른 계정 · 떠남 · 제한이 풀릴 때 지운다(웹과 같다).
|
||||
@@ -861,7 +865,7 @@ data: {"reason":"token-expiring"}
|
||||
그래서 서버와 엔진 사이가 10분 넘게 끊겼다 다시 이으면, 그동안 온 메일은 `state` 로만 알린다(목록에는 나타나고 바탕화면 알림은 없다).
|
||||
"보낸 사람이 나"는 토큰의 주소와 발신 ID(별칭) 주소로 본다(대소문자 무시).
|
||||
- 오래 열린 엔진 연결은 엔진의 동시 요청 한도(4)를 잡아먹지 않는다 — 한 직원의 스트림 8개를 연 채 그 직원 · 다른 직원의 보통 요청과 동시 요청 6개가 모두 됐다(2026-10-28 확인).
|
||||
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때 `end` 없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 브라우저").
|
||||
- 연결은 액세스 토큰이 끝날 때 닫는다(최대 1시간 — 그 전에 `end`). 12단계부터 로그아웃 · 비밀번호 변경 · 세션 끊기로 끊은 세션의 연결은 다음 ping 때 `end` 없이 닫는다(계정을 더 쓸 수 없을 때와 같은 길 — 5절 "로그인된 기기").
|
||||
서버가 종료될 때는 열린 연결을 `end` 로 먼저 닫는다 — 오래 열린 연결이 종료를 붙잡지 않게.
|
||||
알려진 한계: 잠자기 · VPN 재연결 · 망 바꿈으로 반쯤 열린 연결은 서버가 ping 을 쓰다 실패할 때(OS 기본 15분 안팎)까지 자리를 잡는다 —
|
||||
그사이 그 직원의 연결이 5개에 닿으면 새 연결은 429 이고 화면은 1분 확인으로 이어진다.
|
||||
@@ -2119,7 +2123,7 @@ interface FileUploadCreate {
|
||||
| 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) |
|
||||
| 앱 비밀번호 | 엔진 상한(기본 5개 — `x:Authentication.maxAppPasswords`). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 |
|
||||
| 로그인 기록 | 90일 보존(날마다 정리) |
|
||||
| 로그인된 브라우저 | 끝나지 않은 웹메일 세션과 앱 세션(같은 세션 표 — 1절 "앱 로그인"의 `client`) — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일). 앱 서버를 만들 때 화면 이름도 "로그인된 기기"로 함께 정한다 |
|
||||
| 로그인된 기기 | 끝나지 않은 웹메일 세션과 앱 세션(같은 세션 표 — 1절 "앱 로그인"의 `client`) — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일). 화면 이름은 앱 서버와 함께 "로그인된 브라우저"에서 바꿨다(2026-11-06) |
|
||||
|
||||
### 로그인 (`POST /api/auth/login`) — 1절에 더하는 것
|
||||
|
||||
@@ -2258,14 +2262,15 @@ interface FileUploadCreate {
|
||||
엔진은 지운 번호를 다시 쓴다 — 서버 기록은 같은 번호 · 같은 `createdAt` 일 때만 잇는다(만들 때 서버가 기록을 새로 둔다).
|
||||
- 지운 앱 비밀번호로 이미 열린 IMAP 연결은 엔진이 끊지 않는다(알려진 한계 — 다음 연결부터 막힌다).
|
||||
|
||||
### 로그인된 브라우저 (`/api/auth/sessions`)
|
||||
### 로그인된 기기 (`/api/auth/sessions`)
|
||||
|
||||
경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다).
|
||||
경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다 — 앱은 액세스 토큰의 `sid`).
|
||||
12단계에 "로그인된 브라우저"로 만들었고, 앱 세션이 함께 들어가 2026-11-06 화면 이름을 바꿨다.
|
||||
|
||||
```jsonc
|
||||
// GET /api/auth/sessions → 200 (지금 세션이 맨 앞, 그다음 최근 활동 차례)
|
||||
{ "items": [ { "id": "…", "createdAt": "…", "lastActiveAt": "…", "ip": "211.186.95.252",
|
||||
"device": { "browser": "Chrome", "os": "Windows" }, "isCurrent": true } ],
|
||||
"device": { "browser": "Chrome", "os": "Windows" }, "client": "web", "isCurrent": true } ],
|
||||
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 3 } }
|
||||
|
||||
// DELETE /api/auth/sessions/{id} → 200 (data: null)
|
||||
@@ -2277,7 +2282,9 @@ 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` 다.
|
||||
앱 세션(`client: "app"` — 1절 "앱 로그인")은 브라우저가 `ARA SYSTEM 앱`, 운영체제는 User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)` 의 괄호 첫 칸(`Android` · `iOS` — 모르거나 User-Agent 가 없으면 `null`)이다.
|
||||
**앱 이름은 `client` 로만 붙인다** — 웹 세션이 앱 꼴 User-Agent 를 보내면 브라우저는 `null` 이다(꾸민 User-Agent 로 직원 본인의 앱처럼 보이지 않게).
|
||||
- `client`: `"web"` | `"app"`(2026-11-06 더함 — 화면은 이것으로 앱 세션을 세고 휴대폰 아이콘을 보인다).
|
||||
- 12단계 전에 연 세션은 둘 다 `null` 이다.
|
||||
- **끊은 세션은 바로 막는다.** 액세스 토큰에 세션 id(`sid`)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401 `AUTH_001` 로 막는다 — DB 를 묻지 않는다.
|
||||
- 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다.
|
||||
@@ -2303,7 +2310,7 @@ interface FileUploadCreate {
|
||||
|
||||
| 칸 | 값 |
|
||||
| --- | --- |
|
||||
| `method` | `webmail` · `app`(앱 로그인 — 1절 "앱 로그인", 서버를 만들 때 더한다) · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기). 웹은 모르는 값을 그대로 보이므로 `app` 을 더할 때 화면의 이름 글도 함께 더한다 |
|
||||
| `method` | `webmail` · `app`(앱 로그인 — 1절 "앱 로그인", 2026-11-06 — 기기는 앱 세션과 같은 규칙) · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기) |
|
||||
| `isTwoFactor` | 웹메일 · 앱 성공이 코드까지 거쳤는지 |
|
||||
| `failureReason` | `wrongPassword`(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · `wrongCode`(코드 틀림 · 다시 쓴 코드) · `appPasswordRequired`(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 `auth.mfa-required`) |
|
||||
| `appPassword` | 메일 프로그램이 앱 비밀번호로 들어온 경우. `name` 은 그때의 이름이다(지운 뒤에도 남는다) |
|
||||
@@ -2392,7 +2399,7 @@ interface FileUploadCreate {
|
||||
- 2단계를 켜거나 앱 비밀번호를 지워도 이미 열린 IMAP 연결은 엔진이 끊지 않는다(지운 앱 비밀번호의 연결은 다음 명령에서 멈춘다).
|
||||
- 웹훅이 잃는 기록이 있다(엔진 재시작 · 서버가 5분 넘게 받지 못함 · 한도를 넘는 묶음). 같은 초 · 같은 연결의 똑같은 이벤트는 하나로 합쳐진다.
|
||||
- 성공에 쓴 코드는 2분 동안 다시 받지 않으므로, 30초 안에 두 곳에서 연달아 로그인하면 두 번째는 `AUTH_006` 이고 다음 코드를 기다려야 한다.
|
||||
- 로그인 기록의 `appPassword.name` 은 웹메일 밖(엔진 관리 화면)에서 만든 앱 비밀번호면 `null` 이다. `device` 는 웹메일 기록에만 있고, User-Agent 가 없거나 알아보지 못하면 `null` 이다.
|
||||
- 로그인 기록의 `appPassword.name` 은 웹메일 밖(엔진 관리 화면)에서 만든 앱 비밀번호면 `null` 이다. `device` 는 웹메일 · 앱 기록에만 있고(앱은 로그인된 기기와 같은 규칙), User-Agent 가 없거나 알아보지 못하면 `null` 이다.
|
||||
- 끊은 세션 `DELETE /api/auth/sessions/{id}` 의 모양이 틀린 id 는 404 `RES_001`(없는 것과 같다).
|
||||
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
|
||||
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
|
||||
@@ -2408,7 +2415,7 @@ interface FileUploadCreate {
|
||||
2. 코드 실패 한도는 같은 주소 15분 5회 · 24시간 20회다(위 "한도 · 규칙"). 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% 다.
|
||||
3. 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다.
|
||||
4. 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기").
|
||||
5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
|
||||
5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 기기"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
|
||||
|
||||
---
|
||||
|
||||
@@ -2554,18 +2561,18 @@ interface FileUploadCreate {
|
||||
| 퇴사 마무리 | 잠긴 계정만. 엔진 계정 지우기(메일 영구 삭제 — 주소를 다시 쳐서 확인) + 우리 DB 의 그 주소 행 정리 — 같은 주소를 다시 쓸 때 섞이지 않게 |
|
||||
| 링크 끊기 | 신고된 공개 링크 주소로 → `revoked_by = operator` |
|
||||
| 공용 주소 | MailingList 만들기 · 구성원 넣기 / 빼기 · 목록(구성원 받은편지함으로 복사) |
|
||||
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 · 이전 중(끝나는 날) |
|
||||
| 현황 | 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 로그인(웹메일 · 앱 — 2026-11-06 부터 앱도 센다) · 메일함 사용량 · 이전 중(끝나는 날) |
|
||||
| 정책 · 설정 | 2단계 필수(켜기 · 시작일 · 예외), 문의 연락처 |
|
||||
| 메일 이전 (2026-11-02 더함) | 이전 현황(이전 중인 계정 · 끝나는 때 · 메일함 사용량) · 이전용 비밀번호 다시 만들기(한 사람 또는 이전 중인 계정 모두 — 잃었거나 30일을 늘릴 때) · 이전 마치기(한 사람 또는 이전 중인 계정 모두 → 직원용 임시 비밀번호) |
|
||||
|
||||
- 차례가 있는 명령
|
||||
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
|
||||
- 비밀번호 재설정: 앱 비밀번호 지우기(대리 접근) → 웹메일 · 앱 세션 끊기(DB) → 알림 메일 넣기(대리 접근) → 엔진에 임시 비밀번호 + 만료 → 세션 한 번 더 끊기.
|
||||
만료 뒤에는 대리 접근 메서드가 막히고, 알림이 말하는 일(로그아웃 · 앱 비밀번호 지움)은 넣기 전에 끝나 있어야 하기 때문이다. 첫 세션 끊기가 실패하면 알림을 넣기 전에 멈추고,
|
||||
엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다. 마지막 끊기는 그사이 옛 비밀번호로 새로 로그인한 세션을 위한 것이다.
|
||||
- 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근으로 Sieve)을 먼저 걸고 → `authenticate` 빼기 → 세션 끊기. 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다).
|
||||
- 퇴사 마무리: 엔진 계정을 지운 뒤 우리 DB 에서 그 주소의 행을 한 트랜잭션으로 지운다 — 로그인 기록 · 앱 비밀번호 사용 · 2단계 설정 · 서명 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크,
|
||||
그리고 2단계 필수의 예외 주소 목록과 공용 주소(MailingList)의 구성원에서도 뺀다(실행 전 보여 주는 바뀔 것에 든다).
|
||||
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 브라우저 목록은 살아 있는 세션만 보인다).
|
||||
세션 행은 바로 지우지 않는다 — 끊은 기록을 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 에 맡긴다(로그인된 기기 목록은 살아 있는 세션만 보인다).
|
||||
디스크 파일(대용량 · 서명 그림)은 그 뒤에 지우고 못 지운 것은 정리 cron 에 맡긴다. 실행 기록(`ops_audit_log`)은 남기고, 읽기만 하는 현황은 기록하지 않는다.
|
||||
- 비밀번호 재설정은 잠긴 계정을 거절한다(대리 접근이 막혀 있다 — 잠금 풀기가 재설정을 함께 권한다). 이미 만료된 계정을 다시 재설정하면 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다
|
||||
(엔진이 막는다 — 엔진 관리 화면에서 만료를 걸어 앱 비밀번호가 남은 드문 경우는 `[못 함]` 으로 알리고 이어 간다).
|
||||
@@ -2593,7 +2600,7 @@ interface FileUploadCreate {
|
||||
비밀번호를 바꾸기 전에 그 주소의 웹메일 세션을 끊는다 — 임시 비밀번호(만료)로 들어와 새 비밀번호 단계에 묶인 세션이, 만료가 미래로 바뀌면 다음 갱신에서 제한이 풀리기 때문이다(끊지 못하면 바꾸지 않는다).
|
||||
한 사람일 때는 이전 중이거나 비밀번호가 만료된 계정(30일이 지난 이전 계정 · 아직 바꾸지 않은 임시 비밀번호 — 엔진에서는 가를 수 없다 — 바뀔 것에 "지금 비밀번호는 더 쓰지 못합니다")만 받는다. 만료된 계정은 "모두"에 넣지 않는다.
|
||||
직원이 이미 비밀번호를 정한 계정(만료 없음)은 막는다 — 걸면 직원이 로그인하지 못한다. 잠긴 계정 · 2단계 인증을 켠 계정(비밀번호로는 IMAP 에 들어가지 못한다)도 막는다.
|
||||
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 세션 끊기 → **이전 중 로그인 기록 지우기**(우리 DB `login_events` — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게)
|
||||
- 이전 마치기의 차례(계정마다): 앱 비밀번호가 있으면 지우기 → 웹메일 · 앱 세션 끊기 → **이전 중 로그인 기록 지우기**(우리 DB `login_events` — imapsync 의 IMAP 로그인이 엔진 웹훅으로 쌓인다. 직원이 첫날 모르는 IMAP 로그인 수십 줄을 보지 않게)
|
||||
→ 엔진에 직원용 임시 비밀번호 + 만료 → 세션 한 번 더 끊기. **받은편지함 알림 메일은 넣지 않는다**(재설정 알림은 "관리자가 비밀번호를 바꿈"이라 새 직원이 놀란다).
|
||||
한 계정이 실패해도 나머지를 이어 하고, 끝에 주소 · 임시 비밀번호 표를 한 번 보인다(앞에서 만든 것은 실패가 나도 반드시 보인다). 로그인 기록을 지우지 못한 계정은 비밀번호를 바꾸지 않고 멈춘다(넘긴 뒤에는 지울 명령이 없다).
|
||||
잠긴 계정은 마치지 않는다. y/N 뒤 계정마다 다시 읽어 그사이 비밀번호 처지가 바뀌었으면(다른 운영자가 먼저 마침 등) 하지 않는다. 세션 끊는 사유는 운영 도구의 `ended-by-operator` 그대로다.
|
||||
|
||||
Reference in New Issue
Block a user