generated from ttipo/project-base
docs: 12단계 계정 보안 — 계약 5절 · 디자인 보드 넷 · README
2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록을 엔진(Stalwart 0.16.27) 실험 결과에 맞춰 정했다. 엔진이 코드 확인 · 메일 프로그램 막기 · 앱 비밀번호를 맡고, 서버는 첫 코드 확인 · 다시 쓰기 막기 · 실패 한도 · 기록을 맡는다. 사용자 결정(2026-10-31): 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 바로 막는다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+309
-1
@@ -9,7 +9,7 @@
|
||||
- 성공 `{ "success": true, "data": … }` / 실패 `{ "success": false, "error": { "code", "message", "traceId" } }`
|
||||
- 날짜·시각은 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` 을 더한다)
|
||||
(11단계 — 4절 "대용량 첨부"가 `FILE_003` ~ `FILE_008` · `MAIL_016` 을 더한다. 12단계 — 5절 "계정 보안"이 `AUTH_006` ~ `AUTH_009` 를 더한다)
|
||||
- **적용 범위: 1단계는 웹(브라우저) 전용이다.** 인증은 HttpOnly 쿠키뿐이고 토큰을 본문으로 주지 않는다.
|
||||
모바일 앱(Flutter)의 인증 방식(토큰 발급·`flutter_secure_storage`)은 앱 착수 때 이 문서에 추가한다.
|
||||
- **배포 형태: 화면과 API 는 같은 주소(origin)를 쓴다.** 운영은 프론트 nginx 가 `/api` 를 백엔드로 넘기고,
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
> 12단계: 응답에 `status` 가 붙고, 2단계 인증을 켠 직원은 코드 단계를 거친다 — 5절 "로그인".
|
||||
|
||||
```jsonc
|
||||
// 요청
|
||||
{ "email": "ara.kim@arasystem.it.kr", "password": "…" }
|
||||
@@ -74,6 +76,7 @@
|
||||
두 번 이상 지난 토큰이나 30초가 지난 이전 토큰은 위 규칙대로 세션을 끊는다.
|
||||
- 세션은 로그인 후 **최대 30일**(절대 만료)이다. 그 안에서도 리프레시 토큰을 14일 동안 쓰지 않으면 끝난다.
|
||||
- 액세스 토큰은 서버 조회 없이 검증한다. 그래서 세션을 끊어도 이미 발급된 액세스 토큰은 만료(최대 1시간)까지 유효하다.
|
||||
→ 12단계부터는 끊은 세션을 바로 막는다(액세스 토큰의 세션 id + 서버가 기억한 끊은 세션 — 5절 "로그인된 브라우저").
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
@@ -81,6 +84,8 @@
|
||||
|
||||
### `PUT /api/auth/password` — 4단계
|
||||
|
||||
> 12단계: 2단계 인증을 켠 직원은 `otpCode` 를 함께 보낸다 — 5절 "비밀번호 바꾸기".
|
||||
|
||||
```jsonc
|
||||
// 요청 (로그인 필요)
|
||||
{ "currentPassword": "…", "newPassword": "…" }
|
||||
@@ -102,6 +107,7 @@
|
||||
- 바뀌면 그 직원의 **다른 로그인 세션을 모두 끊는다** — 이 요청의 리프레시 쿠키(Path=`/api/auth` 라 이 요청에도 온다)가 가리키는 세션만 남긴다.
|
||||
쿠키가 없거나 다른 직원 것이면 이 세션도 끊긴다(다음 갱신에서 로그인 화면으로). 끊긴 기기의 액세스 토큰은 만료(최대 1시간)까지 쓰인다(위 세션 규칙).
|
||||
휴대폰 · PC 메일 프로그램(IMAP)은 새 비밀번호를 다시 넣어야 한다.
|
||||
→ 12단계부터: 끊긴 기기는 바로 막히고, 앱 비밀번호도 모두 지운다(5절 "비밀번호 바꾸기").
|
||||
- 비밀번호는 바뀌었는데 세션 끊기가 실패하면 200 으로 답하고 오류 로그를 남긴다 — 실패로 답하면 사용자가 옛 비밀번호로 다시 시도하기 때문이다.
|
||||
- 비밀번호는 로그 · 응답에 남기지 않는다. 바꾼 일은 info 로그에 주소(가린 것)만 남긴다.
|
||||
- **클라이언트**: 같은 규칙(8자 이상 · 지금과 다름 · 확인 칸 일치)을 먼저 보고, `AUTH_005` 는 지금 비밀번호 칸 아래에 사전 문구로 보인다. 성공하면 세 칸을 비운다.
|
||||
@@ -2050,3 +2056,305 @@ interface FileUploadCreate {
|
||||
9. **`FILE_006` 의 HTTP 상태** — 추천: 507(저장 공간을 정확히 말하고, 클라이언트의 자동 다시 시도 대상(502 · 503 · 504)과 상태로 갈린다). 대안 503.
|
||||
10. **형식 검사의 MIME** — 추천: 확장자 허용 목록만(위 "올리기" 1). 보안 규칙(`.claude/rules/security.md` "확장자와 MIME 을 둘 다")의 예외라 규칙 문서에 까닭과 함께 적는다(rules 담당).
|
||||
11. **인용 속 링크 상태를 묻는 길** — 추천: 주소 목록을 보내는 `POST /api/share-link-lookups`(이 계약 — 저장하지 않은 쓰기 화면의 인용에도 쓴다). 대안 — 원본 메일 id 로 서버가 본문에서 찾기(`GET /api/messages/{id}/…`) — 클라이언트가 주소를 고르지 않아도 되지만 임시 저장본 · 고친 인용은 따로 다뤄야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 계정 보안 — 12단계 (2026-10-31 확정)
|
||||
|
||||
> 2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 근거는 Stalwart 0.16.27 개발 엔진 실험(2026-10-31 — 엔진 TOTP · 앱 비밀번호 · 웹훅 · 자동 차단).
|
||||
> - 엔진이 하는 것: 코드 확인(앞뒤 30초까지), 2단계를 켠 계정의 비밀번호만 하는 IMAP · SMTP 로그인 막기, 앱 비밀번호 만들기(값은 엔진이 정한다), 로그인 이벤트 보내기(웹훅).
|
||||
> - 서버가 하는 것(엔진이 하지 않는 것): 등록 때 첫 코드 확인, 같은 코드 다시 쓰기 막기, 코드 실패 한도, 웹메일 로그인에서 앱 비밀번호 거절, 로그인 기록 저장 · 보존.
|
||||
> - 웹(브라우저)만이다. 모바일 앱은 앱 착수 때 이 절에 더한다.
|
||||
|
||||
### 공유 계약을 바꾸는 곳 (한데 모음)
|
||||
|
||||
- `POST /api/auth/login`: 요청에 `otpCode`(선택), 응답에 `status`(`signedIn` · `twoFactorRequired`). `twoFactorRequired` 는 200 이고 쿠키가 없다 — `email` · `name` 은 `null`.
|
||||
- `PUT /api/auth/password`: 요청에 `otpCode` — 2단계 인증을 켠 직원만 필수.
|
||||
- 새 API: `/api/two-factor` · `/api/two-factor/enrollments` · `/api/two-factor/activation` · `/api/two-factor/deactivation` · `/api/app-passwords` · `/api/auth/sessions` · `/api/login-events`.
|
||||
- 엔진만 부르는 길 `POST /api/internal/mail-engine-events` — 앞단 nginx 가 막는다(404 `RES_001`).
|
||||
- 에러 코드 `AUTH_006` ~ `AUTH_009`.
|
||||
- 루트 env `MAIL_WEBHOOK_SECRET`(setup · backend 공용 — 운영 필수).
|
||||
|
||||
### 한도 · 규칙
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| 코드 | 숫자 6자리 · 30초 · SHA-1(인증 앱 기본값). 엔진은 앞뒤 한 단계(±30초)까지 받는다 |
|
||||
| 같은 코드 다시 쓰기 | 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다(코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두) → `AUTH_006` |
|
||||
| 코드 실패 | 같은 주소 15분 5회 · 24시간 20회. 같은 IP 는 로그인 실패 한도(1분 20회)에 함께 센다 → 429 `REQ_001` |
|
||||
| 등록 시간 | 등록을 시작하고 10분 안에 켠다. 직원마다 기다리는 등록은 하나다(새로 시작하면 앞의 것은 버린다) |
|
||||
| 앱 비밀번호 | 엔진 상한(기본 5개 — `x:Authentication.maxAppPasswords`). 이름 1~40자(앞뒤 공백을 지운 값, JS 문자열 길이). 권한은 메일 프로그램만 |
|
||||
| 로그인 기록 | 90일 보존(날마다 정리) |
|
||||
| 로그인된 브라우저 | 끝나지 않은 웹메일 세션 — 1절 세션 규칙 그대로(절대 30일 · 미사용 14일) |
|
||||
|
||||
### 로그인 (`POST /api/auth/login`) — 1절에 더하는 것
|
||||
|
||||
```jsonc
|
||||
// 1) 비밀번호
|
||||
{ "email": "ara.kim@arasystem.it.kr", "password": "…" }
|
||||
// 2단계 인증을 켜지 않은 직원 → 200 + Set-Cookie (지금과 같다)
|
||||
{ "status": "signedIn", "email": "ara.kim@arasystem.it.kr", "name": "김아라" }
|
||||
// 켠 직원이고 비밀번호가 맞음 → 200, 쿠키 없음
|
||||
{ "status": "twoFactorRequired", "email": null, "name": null }
|
||||
|
||||
// 2) 코드 — 비밀번호를 한 번 더 싣는다(서버는 비밀번호를 들고 있지 않는다)
|
||||
{ "email": "ara.kim@arasystem.it.kr", "password": "…", "otpCode": "123456" }
|
||||
// → 200 + Set-Cookie { "status": "signedIn", "email": "…", "name": "…" }
|
||||
```
|
||||
|
||||
| 실패 | 코드 |
|
||||
| --- | --- |
|
||||
| 비밀번호 틀림(코드 단계에서도 — 그사이 바뀜) · **앱 비밀번호 모양**(`app` + 1자 + 36자 · 40자 — 엔진에 묻지 않고 실패로 센다) · 그 밖의 1절 규칙 | 401 `AUTH_004` |
|
||||
| 코드 틀림 · 2분 안에 다시 쓴 코드 | 400 `AUTH_006` — 401 이 아닌 까닭은 `AUTH_005` 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다) |
|
||||
| `otpCode` 가 숫자 6자리가 아님 | 400 `VAL_001` |
|
||||
| 코드 실패 한도 · 1절의 한도 | 429 `REQ_001` |
|
||||
|
||||
- 서버의 차례
|
||||
1. 앱 비밀번호 모양이면 거절한다.
|
||||
2. 1절처럼 실패 한 번을 차지하고 엔진에 본인 Basic 으로 묻는다(지금의 `verifyCredentials`).
|
||||
3. 200 → 2단계 인증이 꺼진 직원이다. 로그인한다(코드가 와도 보지 않는다).
|
||||
4. **402**(비밀번호는 맞고 엔진이 2단계를 요구) → 차지한 비밀번호 실패를 돌려준다. `otpCode` 가 없으면 `twoFactorRequired` 로 답한다.
|
||||
있으면: 다시 쓴 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진 `POST /api/auth`(`accountSecret` + `mfaToken`)에 묻는다.
|
||||
`authenticated` 면 로그인한다(차지한 것을 돌려주고 코드를 2분 기억). `failure` 면 `AUTH_006`.
|
||||
5. 401 → `AUTH_004`.
|
||||
- **지금은 402 를 엔진 장애(503 `MAIL_001`)로 다룬다 — 고친다.** 운영자가 엔진 관리 화면에서 OTP 를 켠 직원은 지금 웹메일에 들어오지 못한다. 4단계 비밀번호 바꾸기도 같다.
|
||||
- 코드 단계에서도 비밀번호를 엔진 Basic 으로 먼저 보는 까닭: `/api/auth` 의 `failure` 는 비밀번호 틀림과 코드 틀림을 가르지 않는다. 비밀번호를 추측하는 요청이 코드 실패로 세지지 않게 한다.
|
||||
- `twoFactorRequired` 는 비밀번호가 맞았다는 뜻이다(2단계 로그인의 성질 — 코드 없이는 들어오지 못한다). 비밀번호 추측은 1절의 실패 한도가 그대로 막는다.
|
||||
- 엔진 `/api/auth` 가 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(쓰지 않으면 10분 뒤 사라진다) — 쓰지 않는다.
|
||||
`redirectUri` 는 https 주소여야 한다(`https://<MAIL_DOMAIN>/`). `clientId` 는 고정 이름(`arasystem-webmail`)이다.
|
||||
- **클라이언트**
|
||||
- 코드 단계는 비밀번호를 화면 메모리에만 둔다. 성공 · [다른 계정으로 로그인] · 화면을 떠날 때 지운다.
|
||||
코드 단계에 10분 넘게 머물면 비밀번호를 지우고 첫 단계로 돌아간다(새로고침도 첫 단계).
|
||||
- 코드 칸은 `inputmode="numeric"` · `autocomplete="one-time-code"` 이다. 붙여 넣은 글에서 숫자만 남긴다(공백 · `-`).
|
||||
- `AUTH_006` 은 칸 아래에 보이고(알림 없음) 칸을 비운다. `AUTH_004` 는 첫 단계로 돌아가 비밀번호 칸 아래에 보인다.
|
||||
|
||||
### 비밀번호 바꾸기 (`PUT /api/auth/password`) — 4단계에 더하는 것
|
||||
|
||||
```jsonc
|
||||
{ "currentPassword": "…", "newPassword": "…", "otpCode": "123456" } // otpCode — 2단계 인증을 켠 직원만
|
||||
```
|
||||
|
||||
- 비밀번호를 먼저 본인 Basic 으로 확인한다 — 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은 `forbidden` 으로 거절하기 때문이다.
|
||||
- 200(2단계 꺼짐) → 지금처럼 본인 자격으로 바꾼다.
|
||||
- 402(2단계 켜짐, 비밀번호 맞음) → 게이트웨이 대리 접근으로 `x:AccountPassword/set`(`currentSecret` + `secret` + `otpAuth.otpCode`)을 보낸다. 2단계 인증은 그대로 남는다.
|
||||
- `otpCode` 가 없으면 400 `AUTH_007`(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다). 틀리거나 다시 쓴 코드면 400 `AUTH_006`(코드 실패로 센다).
|
||||
- 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 `AUTH_005` 다(세지 않는다 — 4단계 규칙과 같다).
|
||||
- **바뀌면 그 직원의 앱 비밀번호를 모두 지운다**(2단계 인증과 상관없이 — 대리 접근 `x:AppPassword/set` 의 destroy, Google 과 같다).
|
||||
- 비밀번호가 샌 동안 누군가 웹메일에서 만든 앱 비밀번호가 남지 않게 한다.
|
||||
- 지우기에 실패해도 200 이고 오류 로그를 남긴다. 결과를 모르는 실패(503)에도 지운다 — 다른 세션을 끊는 규칙과 같다.
|
||||
- 화면이 미리 안내한다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다).
|
||||
- 구현 때 확인: 2단계를 켠 채 대리 접근으로 바꾸기의 성공 경로(조사 때는 실패 경로만 돌렸다), 바꾼 뒤 `otpUrl` 이 남는지.
|
||||
|
||||
### 2단계 인증 (`/api/two-factor`)
|
||||
|
||||
```jsonc
|
||||
// GET /api/two-factor → 200
|
||||
{ "isEnabled": true, "enabledAt": "2026-10-31T00:12:00.000Z" } // enabledAt — 웹메일에서 켠 때. 엔진 관리 화면에서 켰으면 null
|
||||
|
||||
// POST /api/two-factor/enrollments → 201 (꺼져 있을 때만, Cache-Control: no-store)
|
||||
{ "enrollmentId": "…", "secret": "JBSWY3DPEHPK3PXP…(32자)",
|
||||
"otpauthUrl": "otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30",
|
||||
"expiresAt": "2026-10-31T00:22:00.000Z" }
|
||||
|
||||
// POST /api/two-factor/activation → 200 (GET 과 같은 모양)
|
||||
{ "enrollmentId": "…", "otpCode": "123456", "currentPassword": "…" }
|
||||
|
||||
// POST /api/two-factor/deactivation → 200 (GET 과 같은 모양)
|
||||
{ "currentPassword": "…", "otpCode": "123456" }
|
||||
```
|
||||
|
||||
- **엔진이 기준이다.** 켜짐은 엔진 `x:AccountPassword/get` 에 `otpAuth.otpUrl` 이 있는지로 본다(대리 접근). 서버는 켠 때(`enabledAt`)만 둔다 — 엔진에서 꺼졌으면 지운다.
|
||||
- 비밀(`secret`): 서버가 만든 160비트를 base32(RFC 4648, 채움 없음 — 32자)로 적는다. 등록을 기다리는 동안(10분)만 서버 메모리에 둔다.
|
||||
켠 뒤에는 엔진에만 있다 — 서버 · DB · 로그 어디에도 두지 않는다.
|
||||
- QR 은 화면이 `otpauthUrl` 로 그린다. 새 프론트 의존성이 든다 — 작은 MIT QR 부호기 하나, `v-html` 없이 SVG 로 그린다(고르는 것은 구현 때).
|
||||
- 켜기
|
||||
1. 받은 코드를 등록의 비밀로 먼저 확인한다(±1 단계). 엔진은 등록 때 코드를 보지 않는다 — 잘못 등록하면 직원이 끄지도 못한다.
|
||||
2. 맞으면 대리 접근으로 `currentSecret` + `otpAuth.otpUrl` 을 보낸다.
|
||||
3. 지금 비밀번호가 틀리면 `AUTH_005` 다(로그인 실패 한도로 센다 — 비밀번호 바꾸기와 같다). 켠 코드는 2분 기억한다.
|
||||
- 끄기: 비밀번호를 본인 Basic 으로 먼저 확인(402) → 다시 쓴 코드인지 확인 → 대리 접근으로 `currentSecret` + `otpAuth: { otpCode, otpUrl: null }`. 이미 꺼져 있으면 200 이다(같은 결과).
|
||||
- 켜고 끄는 일은 웹메일 세션을 끊지 않는다 — 로그인 기록 화면에 [다른 곳 모두 로그아웃]이 따로 있다. 이미 열린 IMAP 연결은 다시 연결할 때까지 남는다(알려진 한계).
|
||||
- 같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기)는 한 번에 하나다 — 처리 중이면 429 `REQ_001`.
|
||||
- 휴대폰 바꾸기: 끈 뒤 다시 켠다(켜진 채 다시 등록하기는 처음 판에 없다).
|
||||
- 휴대폰을 잃었으면 운영자가 엔진 관리 화면에서 그 직원의 OTP 를 지운다(직원 비밀번호 없이 된다 — 절차는 mail/README).
|
||||
|
||||
| 실패 | 코드 |
|
||||
| --- | --- |
|
||||
| 코드 틀림 · 다시 쓴 코드 | 400 `AUTH_006` |
|
||||
| 지금 비밀번호 틀림 · 앱 비밀번호 모양 | 400 `AUTH_005` |
|
||||
| 등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐(등록 · 켜기) | 409 `AUTH_008` — 화면은 처음부터 다시 |
|
||||
| 형식 오류 | 400 `VAL_001` |
|
||||
| 코드 · 비밀번호 실패 한도, 처리 중 | 429 `REQ_001` |
|
||||
| 웹메일로 다루지 않는 계정 | 401 `AUTH_001` |
|
||||
| 엔진 장애 · 결과를 모르는 실패 | 503 `MAIL_001` — 켜기 · 끄기의 결과를 모르면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로) |
|
||||
|
||||
### 앱 비밀번호 (`/api/app-passwords`)
|
||||
|
||||
```jsonc
|
||||
// GET /api/app-passwords → 200 (한 쪽 — 엔진 상한이 작다)
|
||||
{ "items": [ { "id": "b", "name": "회사 PC 아웃룩", "createdAt": "2026-10-31T00:20:00.000Z",
|
||||
"lastUsed": { "at": "2026-10-31T00:41:00.000Z", "method": "imap", "ip": "211.186.95.252" } } ],
|
||||
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 1 } }
|
||||
|
||||
// POST /api/app-passwords → 201 (Cache-Control: no-store) — password 는 이 응답에만
|
||||
{ "name": "회사 PC 아웃룩" }
|
||||
→ { "id": "c", "name": "회사 PC 아웃룩", "createdAt": "…", "lastUsed": null, "password": "app_…(40자)" }
|
||||
|
||||
// DELETE /api/app-passwords/{id} → 200 (data: null) — 이미 없으면 200
|
||||
```
|
||||
|
||||
- 만들기는 대리 접근 `x:AppPassword/set` 의 create 다:
|
||||
`{ description: 이름, permissions: { "@type": "Replace", "permissions": { "authenticate": true, "imapAuthenticate": true, "emailSend": true } } }`.
|
||||
기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다. 만료 · 허용 IP 는 두지 않는다.
|
||||
- 값(`password`)은 엔진이 만들어 만들 때 한 번만 준다. 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음).
|
||||
- 상한을 넘으면 엔진이 `overQuota` 로 거절한다 → 409 `AUTH_009`. 이름이 비었거나 40자를 넘거나 이미 있는 이름(앞뒤 공백을 지운 값이 같음)이면 400 `VAL_001` — 화면이 먼저 보고 칸 아래에 알린다.
|
||||
- 목록은 최근에 만든 것이 위다.
|
||||
화면의 "n / 5개"는 엔진 기본값 5 를 상수로 쓴다 — 엔진 상한을 바꾸면 화면 상수도 함께 바꾼다.
|
||||
- 2단계 인증을 켜지 않은 직원도 만든다(기기마다 따로 끊기 편하다).
|
||||
- 쓰는 곳: IMAP 993 · SMTP 465(사용자 이름 = 메일 주소).
|
||||
- 쓰이지 않는 곳: 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기.
|
||||
- 웹메일 비밀번호를 바꾸면 모두 지워진다(위 "비밀번호 바꾸기").
|
||||
- 이름 바꾸기 · 만료 · 허용 IP 는 처음 판에 없다.
|
||||
- **마지막 사용**: 엔진에 칸이 없어 서버가 웹훅의 `auth.success`(앱 비밀번호 — `id` 가 자격 번호)로 만든다.
|
||||
엔진은 지운 번호를 다시 쓴다 — 서버 기록은 같은 번호 · 같은 `createdAt` 일 때만 잇는다(만들 때 서버가 기록을 새로 둔다).
|
||||
- 지운 앱 비밀번호로 이미 열린 IMAP 연결은 엔진이 끊지 않는다(알려진 한계 — 다음 연결부터 막힌다).
|
||||
|
||||
### 로그인된 브라우저 (`/api/auth/sessions`)
|
||||
|
||||
경로가 `/api/auth` 아래인 까닭: 리프레시 쿠키(Path=`/api/auth`)로 이 요청의 세션을 안다(비밀번호 바꾸기와 같다).
|
||||
|
||||
```jsonc
|
||||
// GET /api/auth/sessions → 200 (지금 세션이 맨 앞, 그다음 최근 활동 차례)
|
||||
{ "items": [ { "id": "…", "createdAt": "…", "lastActiveAt": "…", "ip": "211.186.95.252",
|
||||
"device": { "browser": "Chrome", "os": "Windows" }, "isCurrent": true } ],
|
||||
"pageInfo": { "hasNext": false, "nextCursor": null, "total": 3 } }
|
||||
|
||||
// DELETE /api/auth/sessions/{id} → 200 (data: null)
|
||||
// 지금 세션 → 400 VAL_001 (로그아웃을 쓴다) · 남의 것 · 없는 것 · 끝난 것 → 404 RES_001
|
||||
// DELETE /api/auth/sessions → 200 { "ended": 2 } — 이 요청의 세션만 남기고 모두 끊는다
|
||||
```
|
||||
|
||||
- `lastActiveAt` 은 마지막 갱신(회전) 때다 — 없으면 로그인 때.
|
||||
- `ip` · `device` 는 로그인 · 갱신 때 서버가 둔다.
|
||||
- IP 는 `TRUST_PROXY` 기준 클라이언트 IP 다.
|
||||
- 브라우저는 서버가 User-Agent 를 짧은 이름(`Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet` / `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`)으로 바꾼다 — 서버 순수 함수 하나, 모르면 `null`.
|
||||
- 12단계 전에 연 세션은 둘 다 `null` 이다.
|
||||
- **끊은 세션은 바로 막는다.** 액세스 토큰에 세션 id(`sid`)를 싣는다. 서버는 끊은 세션 id 를 1시간(액세스 토큰의 가장 긴 수명) 기억하고, 가드가 그 토큰을 401 `AUTH_001` 로 막는다 — DB 를 묻지 않는다.
|
||||
- 끊긴 브라우저는 다음 요청에서 갱신이 실패해(세션이 끊김) 로그인 화면으로 간다. 열린 실시간 알림 연결은 다음 ping(25초) 때 끝낸다.
|
||||
- 로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 감지로 세션을 끊을 때도 같다 — 1절의 "끊어도 만료까지 유효"를 바꾼다.
|
||||
- 서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다. 12단계 전에 발급한 토큰(`sid` 없음)은 지금처럼 만료까지 쓰인다.
|
||||
- 리프레시 쿠키가 없거나 남의 것이면 `DELETE /api/auth/sessions` 는 모두 끊는다(비밀번호 바꾸기와 같다). 그때 `isCurrent` 는 모두 false 다.
|
||||
- 페이지: 기본 20 · 최대 100(`limit`), 위치 커서(`shared/utils/offset-cursor.ts`).
|
||||
|
||||
### 로그인 기록 (`GET /api/login-events`)
|
||||
|
||||
```jsonc
|
||||
// GET /api/login-events?limit=20&cursor=…&isSuccess=false → 200 (최근 차례)
|
||||
{ "items": [
|
||||
{ "id": "…", "occurredAt": "…", "method": "webmail", "isSuccess": true, "isTwoFactor": true, "failureReason": null,
|
||||
"ip": "211.186.95.252", "device": { "browser": "Edge", "os": "Windows" }, "appPassword": null },
|
||||
{ "id": "…", "occurredAt": "…", "method": "imap", "isSuccess": true, "isTwoFactor": false, "failureReason": null,
|
||||
"ip": "…", "device": null, "appPassword": { "id": "b", "name": "회사 PC 아웃룩" } },
|
||||
{ "id": "…", "occurredAt": "…", "method": "smtp", "isSuccess": false, "isTwoFactor": false, "failureReason": "appPasswordRequired",
|
||||
"ip": "…", "device": null, "appPassword": null } ],
|
||||
"pageInfo": { "hasNext": true, "nextCursor": "…", "total": 143 } }
|
||||
```
|
||||
|
||||
| 칸 | 값 |
|
||||
| --- | --- |
|
||||
| `method` | `webmail` · `imap`(메일 프로그램 받기) · `smtp`(메일 프로그램 보내기) |
|
||||
| `isTwoFactor` | 웹메일 성공이 코드까지 거쳤는지 |
|
||||
| `failureReason` | `wrongPassword`(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · `wrongCode`(코드 틀림 · 다시 쓴 코드) · `appPasswordRequired`(2단계를 켠 계정에 메일 프로그램이 비밀번호로 — 엔진 `auth.mfa-required`) |
|
||||
| `appPassword` | 메일 프로그램이 앱 비밀번호로 들어온 경우. `name` 은 그때의 이름이다(지운 뒤에도 남는다) |
|
||||
|
||||
- 남기는 것
|
||||
- 웹메일: 서버가 판정한 로그인 시도(성공 · `AUTH_004` · `AUTH_006`). 한도로 막은 요청(429) · 사내 도메인이 아닌 주소 · 막은 계정은 남기지 않는다.
|
||||
- 메일 프로그램: 엔진 웹훅(아래)의 IMAP · SMTP 이벤트.
|
||||
- 남기지 않는 것: 갱신(`/auth/refresh`), 게이트웨이 대리 접근, 엔진 HTTP 리스너의 이벤트(웹메일이 백엔드 IP 로 남기는 것 — 서버 기록과 겹친다).
|
||||
- 90일 보존, 커서 페이지(기본 20 · 최대 100). `isSuccess=false` 면 실패만 준다.
|
||||
- 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만).
|
||||
|
||||
### 엔진 웹훅 받기 (`POST /api/internal/mail-engine-events`)
|
||||
|
||||
- 엔진만 부른다. 세 겹으로 막는다:
|
||||
1. 앞단 nginx 가 `/api/internal/` 을 404 `RES_001` 로 닫는다(두 server 모두).
|
||||
2. 서버는 접속 주소(`X-Forwarded-For` 가 아니라 소켓)가 앱 네트워크(`APP_NETWORK_SUBNET`)인지 본다.
|
||||
3. 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞는지 본다(상수 시간 비교).
|
||||
하나라도 아니면 401 `AUTH_001` 이다 — 본문을 쓰지 않고, warn 로그에 접속 주소만 남긴다.
|
||||
- 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다. 서버가 쓰는 것:
|
||||
- `type`: `auth.success` · `auth.failed` · `auth.mfa-required`
|
||||
- `data.listenerId`: `imap…` → imap, `submission…` · `smtp` → smtp. 그 밖(`http` 등)은 버린다.
|
||||
- `data.remoteIp` · `data.accountName`(주소, 앱 비밀번호면 로컬 이름) · `data.details`
|
||||
- `data.id`: 앱 비밀번호 번호. `details` 가 앱 비밀번호 표시일 때만 쓴다 — IMAP 실패의 `id` 는 명령 태그다.
|
||||
- 사내 도메인의 직원만 남긴다. 사내 목록에 없는 주소의 실패와 막은 계정은 버린다(엔진은 서버 전체의 이벤트를 보낸다).
|
||||
- 엔진 이벤트 `id` 로 겹침을 거른다(엔진이 다시 보낸 묶음). 곧바로 200 으로 답한다(엔진이 5초 기다린다).
|
||||
- 이 경로만 다르게 둔다
|
||||
- 요청 수 한도가 없다(`@SkipThrottle`). 출처 검사는 `Origin` 이 없어 통과한다.
|
||||
- JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000) — 공격이 오면 1초 묶음이 커진다.
|
||||
- 엔진의 대기열은 메모리에 있다. 서버가 5분 넘게 받지 못하거나 엔진이 다시 뜨면 그동안의 메일 프로그램 기록은 빠진다(알려진 한계).
|
||||
|
||||
### 엔진 설정 (setup — release-ops)
|
||||
|
||||
- `x:WebHook` 하나를 둔다(setup 표시로 찾는다).
|
||||
- url `http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`, `signatureKey` = `MAIL_WEBHOOK_SECRET`
|
||||
- `eventsPolicy: include` + `auth.success` · `auth.failed` · `auth.mfa-required` — 기본(`exclude` + 빈 목록)은 모든 이벤트를 보낸다.
|
||||
- `level: trace` — 이 판은 웹훅에 수준을 쓰지 않지만 새 판 문서는 쓴다(기본 info 면 실패 이벤트가 빠진다).
|
||||
- throttle 1000ms · timeout 5000ms · discardAfter 300000ms · lossy false. 바꾼 뒤 리로드한다.
|
||||
- `MAIL_WEBHOOK_SECRET` 이 비면(개발) 웹훅을 만들지 않고 `[주의]` 를 낸다(있던 것은 끈다). 운영은 비면 setup 이 멈춘다.
|
||||
- 엔진 `/api/auth` 는 백엔드가 앱 네트워크로(`MAIL_JMAP_URL` 의 `/api/auth`) 묻는다. 공개 443 은 지금처럼 403 이다.
|
||||
- 앱 비밀번호 상한(`maxAppPasswords`)은 엔진 기본 5 그대로 둔다.
|
||||
|
||||
### 환경 변수
|
||||
|
||||
- `MAIL_WEBHOOK_SECRET` — 루트 env. 영문 · 숫자 32자 이상(Node `--env-file` 이 `#` 부터 자른다).
|
||||
- setup 과 backend(compose `environment`)가 읽는다. 운영 필수이고 예시 값은 거부한다(`env.validation.ts`).
|
||||
- 바꾸면 setup 을 다시 돌리고 backend 를 다시 띄운다.
|
||||
|
||||
### 로그
|
||||
|
||||
- 코드 · 비밀(`secret` · `otpauthUrl`) · 앱 비밀번호 값 · 비밀번호는 어떤 수준에도 남기지 않는다. 앱 비밀번호 이름(사용자 글)도 남기지 않는다(id 만).
|
||||
- info: 2단계 켜기 · 끄기, 앱 비밀번호 만들기 · 지우기, 다른 세션 끊기 — 가린 주소 + id.
|
||||
- warn: 코드 틀림, 서명 · 주소가 맞지 않는 웹훅.
|
||||
- 웹훅 수신은 남기지 않는다(반복되는 정상 흐름). 버린 수 · 남긴 수는 debug.
|
||||
- 엔진 오류의 설명 문장은 남기지 않는다(지금 규칙).
|
||||
|
||||
### 에러 코드 (12단계에 더함 — 제안, 문구는 레지스트리에서 정한다)
|
||||
|
||||
| 코드 | HTTP | 상황 | 문구(제안) |
|
||||
| --- | --- | --- | --- |
|
||||
| `AUTH_006` | 400 | 2단계 인증 코드가 틀림 · 2분 안에 다시 씀(로그인 · 비밀번호 바꾸기 · 켜기 · 끄기) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." |
|
||||
| `AUTH_007` | 400 | 2단계 인증을 켠 직원의 요청에 코드가 없음(다른 곳에서 켠 뒤의 화면) | "인증 앱의 코드를 입력해 주세요." |
|
||||
| `AUTH_008` | 409 | 등록이 없음 · 시간이 지남 · 그사이 다른 곳에서 바뀜 | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| `AUTH_009` | 409 | 앱 비밀번호 상한 | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." — 엔진 상한을 바꾸면 이 문구와 화면 상수도 |
|
||||
|
||||
웹이 분기하는 코드: 넷 모두.
|
||||
|
||||
### 확장 때 옮길 것 (nestjs 스킬의 Redis 목록에 더한다)
|
||||
|
||||
기다리는 등록(비밀 10분), 다시 쓴 코드 기억(2분), 코드 실패 카운터, 같은 직원의 2단계 바꾸기 잠금, 끊은 세션 id 기억(1시간) — 모두 프로세스 메모리다. 인스턴스를 늘리면 공유 저장소로 옮긴다.
|
||||
|
||||
### 운영 확인
|
||||
|
||||
- setup 뒤 `[확인]` 줄에 웹훅이 켜졌는지 본다. 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다.
|
||||
- 휴대폰을 잃은 직원: 엔진 관리 화면 › 그 계정 › 자격 증명(Password)의 OTP 를 지운다(mail/README 에 절차 — 구현 때 실물로 확인).
|
||||
- 직원에게 알릴 것: 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다.
|
||||
|
||||
### 알려진 한계
|
||||
|
||||
- IMAP · SMTP 에서 2단계를 켠 계정에 맞는 비밀번호를 넣으면 "2단계 필요"가 나와 비밀번호가 맞았는지 알 수 있다. 엔진 자동 차단은 틀린 시도만 센다(들어오지는 못한다).
|
||||
- 2단계를 켜거나 앱 비밀번호를 지워도 이미 열린 IMAP 연결은 엔진이 끊지 않는다(지운 앱 비밀번호의 연결은 다음 명령에서 멈춘다).
|
||||
- 웹훅이 잃는 기록이 있다(엔진 재시작 · 서버가 5분 넘게 받지 못함).
|
||||
- 엔진 관리 화면에서 켠 2단계 인증은 `enabledAt` 이 없다.
|
||||
- 복구 코드가 없다 — 휴대폰을 잃으면 운영자에게 문의한다.
|
||||
|
||||
### 이 단계에서 하지 않는 것
|
||||
|
||||
복구 코드, 이 브라우저 기억, 문자 · 메일 코드, 보안 키(WebAuthn) · 패스키, 관리자가 2단계를 강제하기(13단계), 켜진 채 휴대폰 바꾸기,
|
||||
앱 비밀번호 이름 바꾸기 · 만료 · 허용 IP, 새 기기 로그인 알림 메일, 계정 변경 기록(비밀번호 바꾸기 · 2단계 켜기 등), 메일 프로그램의 OAuth 로그인, 모바일 앱.
|
||||
|
||||
### 정한 것 (2026-10-31 추천대로)
|
||||
|
||||
1. 인증 앱에 보일 이름은 "ARA SYSTEM" 이다(앱에는 「ARA SYSTEM (주소)」). 엔진이 받는지 구현 때 확인한다 — 안 되면 "ARASYSTEM".
|
||||
2. 코드 실패 한도는 같은 주소 15분 5회 · 24시간 20회다(위 "한도 · 규칙"). 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% 다.
|
||||
3. 2단계를 켜거나 꺼도 다른 세션을 끊지 않는다 — 로그인 기록 화면에서 따로 끊는다.
|
||||
4. 비밀번호를 바꾸면 앱 비밀번호도 모두 지운다(위 "비밀번호 바꾸기").
|
||||
5. 다른 곳 로그아웃은 바로 적용한다 — 끊은 세션 id 를 가드가 막는다(위 "로그인된 브라우저"). 로그아웃 · 비밀번호 바꾸기의 세션 끊기도 같다.
|
||||
|
||||
Reference in New Issue
Block a user