generated from ttipo/project-base
Compare commits
6 Commits
a8ea204e47
...
781ffb2e16
| Author | SHA1 | Date | |
|---|---|---|---|
| 781ffb2e16 | |||
| 9a72ce91e1 | |||
| d1ef0da488 | |||
| c31442e639 | |||
| f9e3b1c57a | |||
| adb992e6d8 |
@@ -14,6 +14,7 @@
|
||||
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
|
||||
대용량 첨부를 쓰면 운영은 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 포털과 다른 호스트, `CORS_ORIGIN` 에 넣지 않는다)과 `CLAMAV_HOST=clamav` 도 필수다.
|
||||
그 호스트의 DNS · TLS · 외부 프록시 Proxy Host(같은 frontend:8080, 접근 로그 끔)를 함께 만든다 ([[docker]] "외부 리버스 프록시 요구사항")
|
||||
12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다
|
||||
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
|
||||
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
|
||||
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
|
||||
|
||||
@@ -37,8 +37,11 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
|
||||
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
|
||||
│ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
|
||||
│ MAIL_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상)도 setup · backend 가 함께 읽는다 — 운영 필수, 바꾸면 setup 다시 + backend 다시 띄우기
|
||||
│ backend 에는 APP_NETWORK_SUBNET 도 넘긴다(웹훅 받는 쪽이 접속 주소가 앱 네트워크인지 본다)
|
||||
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
|
||||
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
|
||||
BACKEND_PORT · MAIL_WEBHOOK_SECRET 도 읽는다(12단계 — 엔진 웹훅 주소 http://backend:<BACKEND_PORT>/… 와 서명 키, 셸 값과 다르면 멈춘다)
|
||||
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
|
||||
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
|
||||
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
@@ -76,7 +79,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
- 숫자 항목은 형식 검사 (모든 환경)
|
||||
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부),
|
||||
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로)
|
||||
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로),
|
||||
`MAIL_WEBHOOK_SECRET`(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상. 예시 값 `CHANGEME…` 는 setup · backend 모두 거부)
|
||||
- 운영 환경: **`SHARE_PUBLIC_URL` 의 호스트를 `CORS_ORIGIN` 에 넣지 않는다** — 같은 호스트면 공개 호스트 분리(포털에서 공개 링크를 닫는 것)가 꺼져 기동을 막는다.
|
||||
`SHARE_PUBLIC_URL` 의 모양은 백엔드와 nginx 템플릿(`nginx-env.envsh`)이 같은 규칙으로 본다 — 출처만(경로 · 끝의 `/` · 끝 점 없이). 한 번 정하면 바꾸지 않는다(보낸 메일의 링크가 그 주소다)
|
||||
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
|
||||
@@ -96,6 +100,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
|
||||
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
|
||||
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·
|
||||
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)
|
||||
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)·
|
||||
> `MAIL_WEBHOOK_SECRET`(비밀 — 엔진 웹훅 서명 키, setup · backend 공용, 운영 필수 — 만들기 `openssl rand -hex 24`)
|
||||
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
|
||||
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
|
||||
- **2단계 인증(12단계 — 엔진 TOTP)**: 엔진은 등록 때 코드를 확인하지 않고(잘못 등록하면 직원이 끄지도 못한다) 같은 코드를 다시 받으므로 서버가 막는다 —
|
||||
등록 때 첫 코드 확인, 성공에 쓴 코드를 2분 동안 다시 받지 않기(확인하는 동안에도 — 같은 코드의 동시 요청이 둘 다 지나가지 않게), 코드 실패를 따로 세기(같은 주소 15분 5회 · 24시간 20회).
|
||||
코드 단계에서도 비밀번호를 먼저 엔진 Basic 으로 본다 — 엔진 `/api/auth` 의 failure 는 비밀번호 · 코드를 가르지 않아, 그대로면 비밀번호 추측이 코드 실패로 세진다. 비밀(TOTP 키)은 등록 10분 동안만 서버 메모리에 두고 켠 뒤에는 엔진에만 있다
|
||||
- **앱 비밀번호**(메일 프로그램용 — 엔진이 만든 40자): 웹메일 로그인 · 비밀번호 바꾸기는 그 모양(`app` + 1자 + 36자)을 엔진에 묻지 않고 거절한다(엔진에는 웹메일에서 빼는 설정이 없다).
|
||||
만들 때 권한을 메일 프로그램만(`Replace` — authenticate · imapAuthenticate · emailSend)으로 좁힌다 — 기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만든다.
|
||||
비밀번호를 바꾸면 앱 비밀번호를 모두 지운다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게). 값은 만든 응답에만 싣는다(no-store — 로그 · DB 에 두지 않는다)
|
||||
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
|
||||
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
|
||||
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
|
||||
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
|
||||
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
|
||||
@@ -79,6 +88,8 @@
|
||||
전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다.
|
||||
**앞단과 백엔드가 같은 경로를 같게 읽어야 한다** — 백엔드가 대소문자를 가리지 않던 때 `/FILES/` 처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰).
|
||||
그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
|
||||
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔,
|
||||
nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰).
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
|
||||
@@ -139,6 +139,13 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
nginx 가 닫은 경로 404 `RES_001`(공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API — 대소문자 변형도), 백엔드 응답 없음 502·시간 초과 504
|
||||
(그리고 500·503) `SYS_001`. 이름 있는 location 은 두 server 가 함께 넣는 `nginx-api-errors.conf` 에 있다 — `error_page` 를 하나라도 적은 location 은 위의 것을 물려받지 않아 셋을 다 적는다. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
|
||||
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
|
||||
- **`/api/internal/` 도 바깥에서 404 다**(12단계 — 엔진만 부르는 웹훅 받는 경로 `POST /api/internal/mail-engine-events`). 스니펫 `nginx-internal-api-closed.conf`(템플릿 아님 — `/etc/nginx/snippets/`)를
|
||||
두 server 의 `/api/` 블록 안 **다른 정규식보다 먼저** 넣는다 — `~* ^/api/internal(?:[/\x5c]|$)` 라 대소문자 · `%XX`(`%5C` 포함) · 겹 `/` · `..` 변형도 걸린다. 엔진은 app-network 로 backend 에 바로 붙어 이 nginx 를 거치지 않는다
|
||||
(백엔드도 접속 주소가 `APP_NETWORK_SUBNET` 인지 · 본문 서명을 본다 — 세 겹). 운영 배포 때 frontend 이미지를 다시 만들어야 스니펫이 들어간다(`up -d --build`).
|
||||
- **요청 대상에 날 `\`(경로 — `?` 앞) · `#` · 0x80 이상 바이트가 있으면 두 server 모두 location 을 고르기 전에 400 `VAL_001` JSON 이다**(12단계 — server 단계 `if ($request_target_rejected)`).
|
||||
백엔드의 Express(parseurl)는 `#` · 0xA0 가 있으면 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔, nginx 정규식이 닫은 `/api/internal/` · `/api/health` · 공개 API 가 백엔드에서 열렸다
|
||||
(2026-10-31 리뷰 — 백엔드도 같은 글자를 400 으로 막는다, 두 겹). 쿼리의 `\` 는 받는다(브라우저가 주소창 검색어의 `\` 를 그대로 보낼 수 있고, `url.parse` 는 `?` 앞의 `\` 만 바꾼다).
|
||||
닫는 정규식 셋(`/api/internal` · `/api/health` · 포털의 공개 API)도 `[/\x5c]` 로 넓혔다.
|
||||
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
|
||||
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
|
||||
```bash
|
||||
@@ -320,6 +327,9 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
|
||||
11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다.
|
||||
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다.
|
||||
"닫는 경로 시험"(12단계)은 같은 가짜 백엔드로 두 server × 닫는 경로 셋(`/api/internal` · `/api/health` · 공개 API)의 변형이 nginx 가 직접 낸 JSON 인지 본다 —
|
||||
대소문자 · 겹 `/` · `%2F` · `%69` · `%5C` · `..` · absolute-form · 끝 `/` 는 404, 날 `\` · `#` · 0xA0 는 400, 쿼리의 `\` 는 통과, `/api/internals` 같은 이웃 경로는 백엔드로 넘어간다
|
||||
(경계 확인은 wget — busybox `nc` 는 표준 입력이 끝나면 닫아 프록시를 거친 응답을 받지 못한다). 정적 검사는 include 와 `if ($request_target_rejected)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
|
||||
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
|
||||
|
||||
@@ -389,6 +399,17 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
Spamhaus 두 항목(ZEN · DBL)은 `MAIL_SPAMHAUS_DQS_KEY` 가 있으면 시험 항목이 나오는지 본 뒤 DQS(`<키>.zen.dq.spamhaus.net` 등)로 켜고, 없으면 끈다(공개 미러는 소규모 · 비상업 용도라 쓰지 않는다).
|
||||
키는 출력하지 않지만(setup 의 모든 출력이 키를 가린다) 엔진의 스팸 필터 설정에 그대로 저장된다(관리 화면 · 관리 API · 메일 볼륨 백업). 체험 키는 기간이 끝나면 답이 멈추고
|
||||
엔진은 표시 없이 지나간다(fail-open) — 다음 setup 이 끄고 종료 1 로 알리므로 운영은 setup 을 주기적으로 돌려 본다 — mail/README.md "DNS 리졸버와 스팸 차단 목록".
|
||||
- setup 은 엔진 웹훅(`x:WebHook` 하나 — 12단계 메일 프로그램 로그인 기록)을 `MAIL_WEBHOOK_SECRET` 으로 맞춘다: url `http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`(setup 이 `BACKEND_PORT` 도 읽는다),
|
||||
이벤트 `auth.success` · `auth.failed` · `auth.mfa-required` 만(`include` — 기본은 모든 이벤트), `level: trace`, throttle 1초 · discardAfter 5분.
|
||||
0.16.27 의 웹훅에는 이름 칸이 없어 머리글 `X-Arasystem-Setup: [setup.mjs] key=<소금 24hex>.<지문 24hex>` 로 찾는다 — 엔진이 서명 키를 `****` 로만 돌려주므로 키가 바뀌었는지도 이 지문으로 안다.
|
||||
소금은 쓸 때마다 새로 뽑는다(옛 모양은 한 번 맞춘다). 백엔드는 이 머리글을 쓰지 않는다. 키는 사람이 짓지 않는다(`openssl rand -hex 24`).
|
||||
리로드 뒤 메일 컨테이너에서 서명한 빈 묶음으로 받는 곳을 확인한다 — 백엔드 쪽(받는 곳 · 접속 주소 · 키)만 본다(관리 화면에서 서명 키만 바꾼 것은 알아채지 못한다).
|
||||
401 · 404 는 운영에서 실패(개발은 `[주의]`), 닿지 못함 · 5xx 는 `[주의]` 다. 이번 실행에서 키를 바꿨으면 401 을 `[주의]` 로 낮추고 `up -d backend` 뒤 setup 한 번 더를 안내한다
|
||||
(404 안내는 운영 `up -d --build backend frontend` · 개발 `restart backend`). 키를 바꿀 때는 backend 를 먼저 띄우고 setup 을 돌리는 차례를 권한다.
|
||||
운영은 키가 비거나 예시 값이면 아무것도 바꾸기 전에 멈춘다. 개발은 키가 비면 `[주의]` 와 함께 있던 웹훅을 끄고, 예시 값이면 `[주의]` 만 낸다.
|
||||
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
|
||||
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
|
||||
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
|
||||
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
|
||||
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
|
||||
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
|
||||
|
||||
@@ -22,7 +22,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 요청 추적 미들웨어 | `src/common/middleware/request-id.middleware.ts` | `x-request-id` 발급 → 로그·응답 `traceId` ([[logging]]). `configureApp()` **맨 앞**의 express 미들웨어라 본문 파서 오류(413·400·415)에도 붙는다. 들어온 값은 `^[A-Za-z0-9._-]{1,128}$` 일 때만 이어받고 아니면 새로 발급(로그 위조 방지) |
|
||||
| Swagger 래퍼 데코레이터 | `src/common/decorators/api-standard-response.decorator.ts` | `ApiStandardResponse` / `ApiErrorResponse` — 문서를 실응답과 일치시킨다 |
|
||||
| 페이지네이션 DTO | `src/common/dto/pagination.dto.ts` | 커서/오프셋 쿼리 + `PageInfoDto` + `toPaginatedResult()` |
|
||||
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성). **라우팅은 대소문자를 가린다** — 앱은 `createHttpAdapter()`(Express 인스턴스에 `case sensitive routing` 을 먼저 켠 ExpressAdapter — main.ts · e2e 공용)로 만든다. Nest 의 ExpressAdapter 가 생성자에서 `app.use` 를 불러 Express 5 의 라우터를 그때 만들므로 `configureApp()` 에서 켜면 늦다 — `configureApp()` 은 라우터가 대소문자를 가리지 않으면 기동을 멈춘다(2026-10-30 — `/FILES/` 같은 변형이 앞단 nginx 의 받기 상한을 비켜 갔다) |
|
||||
| 전역 HTTP 설정 | `src/app.setup.ts` 의 `configureApp()` | prefix · helmet · cookie-parser · JSON 본문 파서(1mb — 발송 `POST /api/messages` 와 임시 저장 `POST /api/drafts`·`PUT /api/drafts/{id}` 만 4mb(`SEND_MESSAGE_JSON_BODY_LIMIT`, 앞단 nginx 와 같은 값), JSON 만 · **UTF-8 만**. `express.json` 두 개를 직접 붙인다 — 읽기 전 확인 `verify` 를 쓰려고(Nest 의 `useBodyParser` 는 verify 를 받지 않는다)) · 모양 한도 `rejectOversizedJsonBody`(아래 행) · **요청 대상 원문 검사**(`common/middleware/request-target.middleware.ts` + `shared/utils/request-target.ts` — request-id 바로 뒤, 날 `\` · `#` · 제어 문자 · 공백 · 0x7F · 0x80 이상이면 라우팅 전에 400 `VAL_001`. Express 의 parseurl 은 `#` · 0xA0 가 있으면 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꾼다 — 그래서 앞단 nginx 가 정규식으로 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰). Node 24 의 HTTP 파서는 제어 문자 · 날 UTF-8 을 앱 전에 거절한다 — JSON 이 아닌 Node 기본 400) · ValidationPipe(`createValidationPipe()` — `whitelist/forbidNonWhitelisted/transform/stopAtFirstError`) · 필터 · 인터셉터. **main.ts 와 e2e 가 같은 함수를 쓴다** (앱은 `bodyParser: false` 로 생성). **라우팅은 대소문자를 가린다** — 앱은 `createHttpAdapter()`(Express 인스턴스에 `case sensitive routing` 을 먼저 켠 ExpressAdapter — main.ts · e2e 공용)로 만든다. Nest 의 ExpressAdapter 가 생성자에서 `app.use` 를 불러 Express 5 의 라우터를 그때 만들므로 `configureApp()` 에서 켜면 늦다 — `configureApp()` 은 라우터가 대소문자를 가리지 않으면 기동을 멈춘다(2026-10-30 — `/FILES/` 같은 변형이 앞단 nginx 의 받기 상한을 비켜 갔다) |
|
||||
| **JSON 본문 모양 한도** | `src/shared/utils/json-shape.ts` + `app.setup.ts` 의 `JSON_SHAPE_LIMITS` | 배열 하나 1,000 · 값(객체 · 배열 · 원시) 10,000 · 깊이 32. 읽기 전(UTF-8 바이트를 한 번 훑는다 — `verifyJsonBody`, 재귀 없이 넘는 순간 멈춘다)과 읽은 뒤(`rejectOversizedJsonBody`) 두 번 보고, 넘으면 DTO 변환 · 검사 전에 400 `VAL_001`(본문 파서는 가드보다 앞 — 로그인 전 요청도 400). DTO 의 개수 한도로는 막지 못해 둔 것이다(2026-10-28 리뷰 — 1MB 에 작은 객체 8만 개로 7.8초 · +329MB, 깊은 중첩은 500). 새 계약의 본문이 한도를 넘으면 여기부터 올린다 ([[security]]) |
|
||||
| Swagger | `main.ts` → `/api-docs` | 모든 엔드포인트 데코레이터. 인증은 쿠키(`addCookieAuth('access_token')`)만 등록 — Authorization 헤더 인증은 없다 |
|
||||
| 보안 미들웨어 | `app.setup.ts`(helmet · `TRUST_PROXY`) · `main.ts`(CORS 화이트리스트 — 운영은 `CORS_ORIGIN` 만) | 클라이언트 IP 는 `TRUST_PROXY`(홉 수 또는 IP·CIDR 목록, 운영 필수) 기준 — `shared/utils/trust-proxy.ts` |
|
||||
@@ -41,6 +41,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `modules/mail/mail-block.controller.ts` · `mail-block.service.ts` · `mail-blocks.ts` · `modules/mail-user-settings` | 차단은 필터 · 부재중과 같은 Sieve 스크립트의 맨 앞(`jmap/sieve-script.ts`, 원본 판 2 는 차단이 있을 때만 — `jmap/filter-source.ts`). 검사 `mail-blocks.ts`(`@` 앞은 dot-atom · 64자, 여러 사람이 쓰는 메일 서비스 30개는 `mail.constants.ts` — **짝: frontend `shared/constants/mail-block.ts`**, 같은 글자 · 같은 차례 — 한쪽을 고치면 둘 다). 이미 있는 주소의 `POST` 는 꺼진 규칙을 켠다. 설정은 DB(표 `mail_settings` — 마이그레이션 `AddMailSettings`, 직원 주소마다 한 줄 · 없으면 처음 값), `MailUserSettingsService` 가 프로세스마다 60초 기억하고 DB 장애면 처음 값(10초 · `ask`)으로 — 메일 모듈이 발송(`undoSendSeconds`) · 읽기(`externalImages`) 때 읽는다 — 계약 2절 "보낸 사람 차단" · "보내기 · 읽기 설정" |
|
||||
| 파일 저장 · 조각 올리기 (11단계) | `modules/file-storage` | `/api/file-uploads`(만들기 · 16MiB 조각 `PUT` · 끝내기 · 상태 · 지우기) · `/api/file-quota`. 조각은 `Content-Digest`(SHA-256)를 보고 `datasync` 뒤에 적는다, 같은 조각은 `ChunkGate`(프로세스 메모리)가 하나만 들인다, 조각 번호는 `file-id.pipe.ts`(매개변수 타입 `unknown` — 아래 점검 "경로 숫자"). 저장 포트 `FILE_BLOB_STORE` — 지금은 로컬 디스크 어댑터(named volume `/app/files`, 키는 서버가 만든 UUID 만 — `assertKey`). 보관량(직원 · 서버 총량 · 디스크 여유)은 만들기를 `pg_advisory_xact_lock` 안에서 센다(인스턴스를 늘려도 맞다). 끝내기는 `completion_started_at` · 상태 전이 결과를 본다. 악성 검사 `clamav-client.ts`(INSTREAM 직접 구현 — 판정 글은 `CLAMAV_REPLY` · `classifyClamavReply` 한 곳: 암호 · 압축 안 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error) · 검사 차례 `file-scan.service.ts`(DB pending · 동시 2) · 정리 cron `file-storage.cleanup.ts`. 확장자 허용 목록만 본다 — [[security]] "파일 업로드"의 예외. 계약 4절 |
|
||||
| 공개 링크 · 내려받기 (11단계) | `modules/file-sharing` · `modules/mail/mail-large-files.service.ts` · `large-file-block.ts` · `share-link.controller.ts` | 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 정화 · 지문 · 중복 확인 뒤 본문에 덩이를 넣는다(`large-file-block.ts` — 표시 `<!--ara-large-files:v1-->` · `data-ara-large-files` 는 서버만 넣고 두 정화기가 사용자 HTML 에서 지운다, 경계 `bodyQuoteStart`). 링크는 "확실한 실패"(`SendNotStartedException` — 형식으로 가른다)에서만 거둔다. 임시 저장은 머리글 `X-Ara-Large-Files`(보내는 메일에는 없다). 공개 경로 `/api/public-links/{token}`(로그인 없음) — 공개 호스트 미들웨어 `public-host.ts`(공개 호스트에서는 이 경로만 · 포털에서는 닫음, Host 를 스스로 정규화. 경로는 원문 · 라우터의 `req.path` · WHATWG URL · 퍼센트를 푼 원문 네 모양을 모두 본다 — 공개 호스트는 모두 공개 경로일 때만, 포털은 하나라도 공개 경로면 닫고, 읽지 못한 모양이 있으면 둘 다 닫는다. 라우터가 읽는 모양을 빼면 절대 주소 꼴 · `\` 요청이 막기를 지난다 — 2026-10-30), 파일 응답 `serveFile`(octet-stream · attachment · nosniff · CSP sandbox · no-store · Range `shared/utils/http-range.ts`), 받은 수는 조건부 UPDATE 한 문장, 종료 때 진행 중 응답을 `BeforeApplicationShutdown` 에서 끊는다. 읽기 화면의 `largeFiles` 는 표시 · 우리 출처 토큰 · 링크 주인 == From 이 모두 맞을 때만. 기간은 `shared/utils/kst-date.ts`(보낸 날부터 30일째 23:59:59 KST). 계약 4절 |
|
||||
| 계정 보안 (12단계) | `modules/account-security` · `modules/auth` 의 세션 부분 · `modules/mail/jmap/jmap-account-security.provider.ts` | 2단계 인증(엔진 TOTP — 계약 5절): 등록 `two-factor-enrollments.ts`(비밀 160비트 · 메모리 10분 · 직원마다 하나) · 켜기 · 끄기 `two-factor.service.ts`(엔진이 기준 — `enabledAt` 만 표 `two_factor_settings`) · **코드 가드 `two-factor-code-guard.ts`** — 실패 차지 · 다시 쓴 코드 확인 · (주소, 코드) "확인 중" 차지를 await 없이 한 번에 하고(같은 코드의 동시 요청이 둘 다 지나가지 않게), 받으면 그 자리에서 2분 기억한다. 결과는 받음 · 거절 · 쓰지 않음(`unused` — 새 비밀번호 거절처럼 코드와 상관없는 실패는 세지도 기억하지도 않는다). 코드 실패 카운터는 `login-attempt.service.ts`(같은 주소 15분 5 · 24시간 20 + IP 의 로그인 실패 한도). TOTP · base32 는 `shared/utils/totp.ts`(RFC 6238 — 새 의존성 없음, ±1 단계 상수 시간 비교). 잠금 `two-factor-locks.ts`(같은 직원의 2단계 바꾸기는 한 번에 하나). 로그인 2단계는 `auth.service.ts` — 앱 비밀번호 모양 거절 → 실패 차지 → 본인 Basic → **402 면 대리 접근 프로필로 정식 주소**(막는 계정의 별칭은 `AUTH_004`) → 코드는 엔진 `POST /api/auth`(mfaToken). 앱 비밀번호 `app-passwords.*`(엔진 `x:AppPassword` — 권한 `Replace` 셋, 직원마다 만들기는 한 번에 하나, 마지막 사용은 표 `app_password_usages` — 번호 · createdAt 이 같을 때만 잇는다, 비밀번호를 바꾸면 모두 지운다). 로그인된 브라우저 `modules/auth/auth-sessions.*`(경로 `/api/auth/sessions` — 리프레시 쿠키로 지금 세션을 안다) · **끊은 세션 기억 `revoked-sessions.ts`**(액세스 토큰의 `sid` — 1시간, 기동 때 DB 에서 채운다, 끊는 모든 길이 `revokeWhere` 한 곳을 지난다 — 가드가 막고 실시간 알림은 다음 ping 때 닫는다). User-Agent → 짧은 이름 `shared/utils/user-agent.ts`. 로그인 기록 `login-events.*`(표 `login_events` — 90일 정리 cron `login-events.cleanup.ts`, 위치 커서 `login-event-cursor.ts`). 엔진 웹훅 `mail-engine-events.*` — 본문을 받기 전에 소켓 주소(`APP_NETWORK_SUBNET` — `shared/utils/cidr.ts`)와 키 유무를 보고, 원문 HMAC 을 상수 시간으로, 이 경로만 JSON 한도를 따로(4MB · 배열 10,000 · 값 200,000), 겹침은 내용 지문(`dedupe_key` 유일 인덱스 — 엔진은 다시 보낼 때 id 를 새로 붙인다), 1,000행씩 `ON CONFLICT DO NOTHING`, DB 실패는 5xx(엔진이 다시 보낸다), 빈 묶음 200(setup 의 받는 곳 확인). 마이그레이션 `AddAccountSecurity` |
|
||||
| Rate Limiting | `app.module.ts` 의 `ClientIpThrottlerGuard`(APP_GUARD, `common/guards/client-ip-throttler.guard.ts`) | 초과 시 429 → `REQ_001`. 루트 `.env` 의 `THROTTLE_*` 로 조정. `ThrottlerGuard` 를 상속해 tracker 만 바꿨다 — IP 는 `shared/utils/client-ip.ts` 의 `clientIpKey()`(IPv6 는 /64 로 묶음, 로그인 실패 카운터와 같은 함수) |
|
||||
| 로깅 | `shared/logger/winston.config.ts` | `console.log` 대신 사용 ([[logging]]) |
|
||||
| env 검증 | `shared/config/env.validation.ts` | 잘못된 설정이면 **기동 실패**(fail-fast) ([[env-structure]]) |
|
||||
@@ -247,6 +248,10 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> 외부 이미지 표의 암호 키(`mail-image-token.ts` — 프로세스가 뜰 때 무작위라 그대로면 다른 인스턴스로 간 이미지 요청이 모두 404 → 공유 비밀(env)로),
|
||||
> 외부 이미지의 동시 수(`mail-image-proxy.service.ts` — 서버 16 · 직원 6 을 인스턴스마다 따로 센다. 틀리지는 않고 바깥으로 나가는 수 · 메모리가 인스턴스 수만큼 는다 → 필요하면 공유 셈),
|
||||
> 보내기 · 읽기 설정의 60초 기억(`mail-user-settings.service.ts` — 다른 인스턴스에서 바꾸면 60초까지 옛 값(보내기 취소 초 · 외부 이미지)을 쓴다 → 무효화 알림이나 공유 캐시),
|
||||
> 계정 보안(12단계)의 메모리 상태 — 기다리는 2단계 등록(`two-factor-enrollments.ts` — 비밀 10분), 코드 가드의 "확인 중" 차지 · 성공에 쓴 코드 기억(`two-factor-code-guard.ts` — 2분),
|
||||
> 코드 실패 카운터(`login-attempt.service.ts` 의 codeFailures · dailyCodeFailures), 2단계 바꾸기 잠금(`two-factor-locks.ts`) · 앱 비밀번호 만들기 차례(`AppPasswordsService.creating`) —
|
||||
> 그대로면 인스턴스마다 따로 세고 기억해 같은 코드를 다른 인스턴스에서 다시 쓸 수 있다 → 공유 저장소(`SET NX PX` · 카운터). 끊은 세션 기억(`revoked-sessions.ts` — 1시간, 기동 때 DB 에서 채움) —
|
||||
> 그대로면 다른 인스턴스에서 끊은 세션의 액세스 토큰이 만료까지 쓰인다 → 공유 저장소나 끊을 때 알림. 로그인 기록 정리 cron · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다.
|
||||
> 대용량 첨부(11단계)의 같은 조각 게이트 · 조각 동시 수(`file-storage.service.ts` 의 `ChunkGate` — 그대로면 두 인스턴스가 같은 조각을 함께 쓴다 → 잠금), 검사 차례(`file-scan.service.ts` — 행 잠금이 없어
|
||||
> 두 인스턴스가 같은 파일을 두 번 검사한다 → `FOR UPDATE SKIP LOCKED`), 정리 cron 둘(인스턴스마다 돈다), 파일 바이트(로컬 볼륨 → 같은 저장 포트 `FILE_BLOB_STORE` 의 공유 저장 어댑터(S3 호환)) —
|
||||
> 만들기 잠금(`pg_advisory_xact_lock`) · 받은 수 셈(조건부 UPDATE)은 DB 라 그대로 맞다:
|
||||
@@ -414,6 +419,8 @@ dc exec backend npm uninstall <패키지>
|
||||
메일을 지우지 않는다 — `Email/set update` 만 쓴다(옮기기는 `ifInState` — 어긋나면 다시 확인, 표시는 조건 없이).
|
||||
**붙잡힌 메일**(9단계 — 보내기 취소 · 예약을 기다리는 보낸 사본)을 옮기거나 지우는 새 경로를 만들면 `pending-send.ts` 의 `readHeldEmails` 로 먼저 묻고 막는다
|
||||
(`MAIL_015` · `failedIds` · `skippedIds` — 읽지 못하면 바꾸지 않고 `MAIL_001`). 엔진은 옮기거나 지운 메일도 시간이 되면 보낸다.
|
||||
**엔진 Basic 의 402 는 장애가 아니다**(12단계) — 비밀번호는 맞고 그 직원이 2단계 인증(OTP)을 켰다는 뜻이다. 웹메일 로그인은 코드 단계로, 비밀번호 바꾸기는 대리 접근 + `otpAuth.otpCode` 로 간다(지금까지 `MAIL_001` 로 다뤄 OTP 를 켠 직원이 들어오지 못했다).
|
||||
2단계 · 앱 비밀번호 · 웹훅 설정을 고칠 때는 계약 5절의 엔진 사실(엔진은 등록 때 코드를 보지 않는다 · 같은 코드를 다시 받는다 · 앱 비밀번호 기본 권한은 앱 비밀번호를 또 만든다)을 함께 본다.
|
||||
**발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인).
|
||||
**대용량 첨부의 표시**(11단계 — `data-ara-large-files` · `<!--ara-large-files…-->` · `data-ara-quote-start`)는 서버만 넣는다 — 두 정화기가 사용자 HTML 의 `data-ara-*` · 주석을 지우는지
|
||||
정화를 고칠 때 함께 본다(지우지 못하면 남이 꾸민 덩이가 첨부처럼 보인다). 보내기에 실패해도 링크는 "확실한 실패"(`SendNotStartedException`)에서만 거둔다 — 결과를 모르면 남긴다(메일이 나갔을 수 있다).
|
||||
|
||||
@@ -64,6 +64,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
|
||||
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
|
||||
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
|
||||
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다 — 계약 5절, 디자인 README "12단계" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
|
||||
| 읽기 화면 본문 안내 | `src/shared/utils/mail-body-notices.ts` · `shared/constants/mail.ts` 의 `MAIL_BODY_NOTICES` | 서버의 `isHtmlBodyOmitted`·`isBodyTruncated`(계약 2절 "읽기 한도")를 외부 이미지 차단과 같은 안내 줄로 보인다(`.reader__notice[data-notice]`) |
|
||||
|
||||
@@ -184,6 +184,10 @@ MAIL_GATEWAY_PASSWORD=change-me-in-local-env
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로 서명하고(HMAC-SHA256)
|
||||
# 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다. 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다)
|
||||
# 비우면 setup 이 웹훅을 두지 않는다(개발만 — 그동안 메일 프로그램 로그인 기록이 남지 않는다). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=changemeinlocalenv00000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
|
||||
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
|
||||
@@ -216,6 +216,12 @@ MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — **운영 필수**. 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로
|
||||
# 서명하고(HMAC-SHA256) 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다 — 비었거나 CHANGEME 가 남아 있으면 둘 다 멈춘다.
|
||||
# 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다 — 엔진 관리 화면에 이 키의 지문이 보인다)
|
||||
# 새 이미지를 띄우기(up) 전에 넣는다 — 없으면 백엔드가 env 검사에서 멈춘다. 바꾸면 backend 를 다시 띄우고(up -d backend) setup 을 다시 실행한다 —
|
||||
# 그 사이에 엔진이 보낸 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=CHANGEMEBEFOREDEPLOY000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
|
||||
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
|
||||
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
|
||||
|
||||
@@ -189,6 +189,16 @@ jobs:
|
||||
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do
|
||||
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
||||
done
|
||||
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
|
||||
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
|
||||
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
|
||||
for form in plain rendered; do
|
||||
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
done
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소)
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
|
||||
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
@@ -279,6 +289,72 @@ jobs:
|
||||
for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done
|
||||
docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; }
|
||||
|
||||
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
|
||||
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
|
||||
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
|
||||
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
|
||||
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
|
||||
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
|
||||
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
|
||||
- name: 닫는 경로 시험
|
||||
run: |
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
docker exec -i "$name" sh -s <<'EOF'
|
||||
fails=0
|
||||
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
|
||||
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; [ -n "$WAIT" ] && sleep 1; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
||||
NR == 1 { s = $2 }
|
||||
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
||||
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
||||
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
||||
}
|
||||
# 찍을 때 0x80 이상 바이트는 ~ 로
|
||||
check() {
|
||||
label=$(printf '%s' "$2" | tr '\200-\377' '~')
|
||||
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
|
||||
}
|
||||
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
BS=$(printf '\')
|
||||
NB=$(printf '\240')
|
||||
E=mail-engine-events
|
||||
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
||||
F=11111111-2222-4333-8444-555555555555
|
||||
for host in portal.ci.test files.ci.test; do
|
||||
echo "== $host — /api/internal 은 404"
|
||||
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
|
||||
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
|
||||
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
|
||||
expect "404 application/json RES_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
|
||||
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
|
||||
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
|
||||
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
|
||||
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
|
||||
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
|
||||
expect "400 application/json VAL_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
|
||||
done
|
||||
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
|
||||
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
|
||||
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
|
||||
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
|
||||
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
|
||||
|
||||
@@ -8,6 +8,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
|
||||
import { OriginGuard } from '@/common/guards/origin.guard';
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
@@ -66,6 +67,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
HealthModule,
|
||||
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
|
||||
AuthModule,
|
||||
// 계정 보안 (12단계) — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기 (인증 모듈도 가져온다)
|
||||
AccountSecurityModule,
|
||||
// 웹메일 API (Stalwart JMAP 어댑터)
|
||||
MailModule,
|
||||
// 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다)
|
||||
|
||||
@@ -19,6 +19,16 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { requestTargetMiddleware } from '@/common/middleware/request-target.middleware';
|
||||
import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
} from '@/modules/account-security/account-security.settings';
|
||||
import {
|
||||
createMailEngineEventsBodyParser,
|
||||
isMailEngineEventsRequest,
|
||||
} from '@/modules/account-security/mail-engine-events.parser';
|
||||
import {
|
||||
FILE_SHARING_SETTINGS,
|
||||
type FileSharingSettings,
|
||||
@@ -182,7 +192,8 @@ export function verifyJsonBody(
|
||||
/**
|
||||
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
|
||||
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
|
||||
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
|
||||
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다.
|
||||
* 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다
|
||||
*/
|
||||
export function rejectOversizedJsonBody(
|
||||
req: Request,
|
||||
@@ -190,7 +201,10 @@ export function rejectOversizedJsonBody(
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const body: unknown = req.body;
|
||||
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
|
||||
const limits = isMailEngineEventsRequest(req)
|
||||
? MAIL_ENGINE_EVENTS_SHAPE_LIMITS
|
||||
: JSON_SHAPE_LIMITS;
|
||||
const problem = findJsonShapeProblem(body, limits);
|
||||
if (problem === null) {
|
||||
next();
|
||||
return;
|
||||
@@ -241,6 +255,10 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts).
|
||||
// Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31)
|
||||
app.use(requestTargetMiddleware);
|
||||
|
||||
// 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다.
|
||||
// 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts)
|
||||
const sharing = app.get<FileSharingSettings>(FILE_SHARING_SETTINGS, {
|
||||
@@ -272,6 +290,15 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
|
||||
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
|
||||
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
|
||||
//
|
||||
// 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고
|
||||
// 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다.
|
||||
// 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts)
|
||||
const accountSecurity = app.get<AccountSecuritySettings>(
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
{ strict: false },
|
||||
);
|
||||
app.use(createMailEngineEventsBodyParser(accountSecurity));
|
||||
app.use(
|
||||
json({
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface AuthenticatedUser {
|
||||
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
|
||||
*/
|
||||
readonly accessTokenExpiresAt?: number;
|
||||
/**
|
||||
* 이 액세스 토큰의 로그인 세션 id (auth_sessions.id) — 가드를 거친 요청에만, 12단계 뒤에 발급한 토큰에만 있다.
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,6 +51,30 @@ const COMMON_ERROR_CODES = {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '지금 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
// 2단계 인증 코드가 틀렸거나 2분 안에 다시 쓴 코드 — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기 (계약 5절).
|
||||
// 401 이 아닌 까닭은 AUTH_005 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다)
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '인증 앱의 코드를 입력해 주세요.',
|
||||
},
|
||||
// 2단계 인증 등록이 없음 · 10분 지남 · 그사이 다른 곳에서 바뀜 — 화면은 처음부터 다시 (계약 5절)
|
||||
AUTH_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
|
||||
},
|
||||
// 앱 비밀번호 상한(엔진 기본 5개) — 엔진 상한을 바꾸면 이 문구와 화면 상수도 함께 (계약 5절)
|
||||
AUTH_009: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
|
||||
import { requestIdMiddleware } from './request-id.middleware';
|
||||
import { requestTargetMiddleware } from './request-target.middleware';
|
||||
|
||||
/** 원문 바이트 그대로 요청을 보내고 응답(상태 · 머리글 · 본문)을 읽는다 — supertest 는 경로를 고쳐 보낸다(`\` → `/` 등) */
|
||||
function sendRaw(
|
||||
server: Server,
|
||||
head: Buffer,
|
||||
): Promise<{ status: number; headers: string; body: string }> {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const [headers, ...rest] = text.split('\r\n\r\n');
|
||||
resolve({
|
||||
status: Number(headers.split(' ')[1]),
|
||||
headers: headers.toLowerCase(),
|
||||
body: rest.join('\r\n\r\n'),
|
||||
});
|
||||
});
|
||||
socket.write(head);
|
||||
});
|
||||
}
|
||||
|
||||
const requestLine = (method: string, target: string) =>
|
||||
Buffer.from(
|
||||
`${method} ${target} HTTP/1.1\r\nHost: portal.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
||||
'latin1',
|
||||
);
|
||||
|
||||
describe('요청 대상 원문 검사 미들웨어 (라우팅 전에 400 VAL_001)', () => {
|
||||
let server: Server;
|
||||
const reached: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.set('case sensitive routing', true);
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(requestTargetMiddleware);
|
||||
app.get('/api/health', (_req, res) => {
|
||||
reached.push('health');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/internal/mail-engine-events', (_req, res) => {
|
||||
reached.push('webhook');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.get('/api/search', (req, res) => {
|
||||
reached.push('search');
|
||||
res.json({ q: req.query.q });
|
||||
});
|
||||
server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
reached.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
['POST', '/api/internal\\mail-engine-events#x'],
|
||||
['GET', '/api/health\\#a'],
|
||||
['GET', '/api/health#a'],
|
||||
['GET', 'http://portal.test/api/internal\\mail-engine-events'],
|
||||
])(
|
||||
'%s %s — 처리기에 닿지 않고 표준 실패 형식의 400 VAL_001 (traceId · 연결 닫기)',
|
||||
async (method, target) => {
|
||||
const res = await sendRaw(server, requestLine(method, target));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers).toContain('connection: close');
|
||||
const body = JSON.parse(res.body) as {
|
||||
success: boolean;
|
||||
error: { code: string; traceId?: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe('VAL_001');
|
||||
expect(body.error.traceId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(res.headers).toContain(`x-request-id: ${body.error.traceId}`);
|
||||
expect(reached).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('정상 경로 · 퍼센트 인코딩한 한글은 그대로 지나간다', async () => {
|
||||
const health = await sendRaw(server, requestLine('GET', '/api/health'));
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const search = await sendRaw(
|
||||
server,
|
||||
requestLine('GET', '/api/search?q=%ED%95%9C%EA%B8%80'),
|
||||
);
|
||||
expect(search.status).toBe(200);
|
||||
expect(JSON.parse(search.body)).toEqual({ q: '한글' });
|
||||
expect(reached).toEqual(['health', 'search']);
|
||||
});
|
||||
|
||||
it('0xA0 · 제어 문자가 든 대상도 막는다 — 지금 Node 는 HTTP 파서가 먼저 거절하지만 파서가 느슨해져도 여기서 막는다', () => {
|
||||
for (const target of ['/api/a b', '/api/a\u0000b', '/api/a\u007fb']) {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const setHeader = jest.fn();
|
||||
const next = jest.fn();
|
||||
requestTargetMiddleware(
|
||||
{
|
||||
originalUrl: target,
|
||||
url: target,
|
||||
header: () => 'trace-1',
|
||||
} as unknown as Request,
|
||||
{ status, json, setHeader } as unknown as Response,
|
||||
next,
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: expect.any(String) as unknown,
|
||||
traceId: 'trace-1',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { hasForbiddenRequestTargetChar } from '@/shared/utils/request-target';
|
||||
import { REQUEST_ID_HEADER } from './request-id.middleware';
|
||||
|
||||
/**
|
||||
* 요청 대상 원문 검사 미들웨어 (express) — `configureApp()` 이 요청 추적 ID 바로 뒤, 라우팅 · 본문 파서 앞에 붙인다.
|
||||
* 원문 대상(`req.originalUrl` · `req.url`)에 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 표준 실패 형식의 400 VAL_001 로 끝낸다
|
||||
* (규칙과 까닭은 shared/utils/request-target.ts). 앞단 nginx 도 server 단계에서 400 으로 막는다(release-ops — 두 겹).
|
||||
* 본문을 받지 않고 끝내므로 연결을 닫는다. 로그를 남기지 않는다(두드려 보는 요청 — 대상 원문에 로그를 흐트러뜨리는 글자가 들 수 있다)
|
||||
*/
|
||||
export function requestTargetMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const original = req.originalUrl ?? req.url;
|
||||
if (
|
||||
!hasForbiddenRequestTargetChar(original) &&
|
||||
(req.url === original || !hasForbiddenRequestTargetChar(req.url))
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.BAD_REQUEST).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: getErrorDefinition('VAL_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 계정 보안 표 (docs/mail/api-contract.md 5절 "계정 보안" — 12단계).
|
||||
* - login_events: 로그인 기록(웹메일 · 메일 프로그램) — 90일 보존. 직원 · 일어난 때 · 차례 번호 인덱스(커서 페이지),
|
||||
* 일어난 때 인덱스(정리), 엔진 이벤트의 겹침 거르기 키 유일 인덱스(웹메일 기록은 null — 여럿이어도 된다)
|
||||
* - app_password_usages: 앱 비밀번호의 서버 기록(이름 · 엔진의 만든 때 · 마지막 사용) — 직원 · 앱 비밀번호 id 마다 한 줄
|
||||
* - two_factor_settings: 웹메일에서 2단계 인증을 켠 때 — 직원 주소마다 한 줄 (비밀은 두지 않는다)
|
||||
* - auth_sessions: 로그인 · 갱신한 클라이언트의 IP · User-Agent 칸(12단계 전의 세션은 null)과 직원 주소 인덱스(로그인된 브라우저 목록 · 다른 곳 모두 끊기)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/account-security/*.entity.ts · modules/auth/auth-session.entity.ts)
|
||||
*/
|
||||
export class AddAccountSecurity1793415011070 implements MigrationInterface {
|
||||
name = 'AddAccountSecurity1793415011070';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "login_events" ("id" BIGSERIAL NOT NULL, "user_email" character varying(254) NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "method" character varying(32) NOT NULL, "is_success" boolean NOT NULL, "is_two_factor" boolean NOT NULL, "failure_reason" character varying(32), "ip" character varying(64), "user_agent" character varying(512), "app_password_id" character varying(16), "app_password_name" character varying(40), "dedupe_key" character(43), CONSTRAINT "PK_5cd6102474db62bc181590b9902" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_occurred_at" ON "login_events" ("occurred_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_login_events_dedupe_key" ON "login_events" ("dedupe_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_user_occurred" ON "login_events" ("user_email", "occurred_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_password_usages" ("user_email" character varying(254) NOT NULL, "app_password_id" character varying(16) NOT NULL, "name" character varying(40) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_used_at" TIMESTAMP WITH TIME ZONE, "last_used_method" character varying(8), "last_used_ip" character varying(64), CONSTRAINT "PK_6db68c01bf629755c5eeaf95322" PRIMARY KEY ("user_email", "app_password_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "two_factor_settings" ("user_email" character varying(254) NOT NULL, "enabled_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e1f907880f3d367cca2717934b6" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "ip" character varying(64)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "user_agent" character varying(512)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_user_email" ON "auth_sessions" ("user_email") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "user_agent"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "ip"`);
|
||||
await queryRunner.query(`DROP TABLE "two_factor_settings"`);
|
||||
await queryRunner.query(`DROP TABLE "app_password_usages"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_user_occurred"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_login_events_dedupe_key"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "login_events"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JsonShapeLimits } from '@/shared/utils/json-shape';
|
||||
|
||||
/**
|
||||
* 계정 보안(12단계 — 계약 5절 "계정 보안") 상수 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅.
|
||||
* 로그인 · 코드 실패 한도는 같은 카운터를 쓰므로 modules/auth/auth.constants.ts 에 있다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 인증 앱에 보일 발급자 이름 — 앱에는 「ARA SYSTEM (주소)」로 저장된다 (계약 5절 "정한 것" 1).
|
||||
* 엔진(totp-rs from_url)이 이 이름이 든 otpauth 주소를 받고 그 주소로 코드를 확인하는 것을 개발 엔진에서 확인했다 (2026-10-31)
|
||||
*/
|
||||
export const TWO_FACTOR_ISSUER = 'ARA SYSTEM';
|
||||
/** 비밀 길이 — 160비트(base32 32자) */
|
||||
export const TWO_FACTOR_SECRET_BYTES = 20;
|
||||
/** 등록을 시작하고 켜기까지 — 10분 (계약 5절 "한도 · 규칙") */
|
||||
export const TWO_FACTOR_ENROLLMENT_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* 같은 직원이 성공에 쓴 코드를 다시 받지 않는 시간 — 2분 (코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두).
|
||||
* 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다 — 2026-10-31 확인)
|
||||
*/
|
||||
export const TWO_FACTOR_CODE_REUSE_MS = 2 * 60_000;
|
||||
|
||||
/** 앱 비밀번호 이름 — 1~40자 (앞뒤 공백을 지운 값, JS 문자열 길이) */
|
||||
export const APP_PASSWORD_NAME_MAX_LENGTH = 40;
|
||||
/**
|
||||
* 앱 비밀번호의 권한 — 메일 프로그램만(로그인 · IMAP · 메일 보내기). 엔진 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고
|
||||
* 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다 (계약 5절 "앱 비밀번호", 2026-10-31 확인)
|
||||
*/
|
||||
export const APP_PASSWORD_PERMISSIONS = {
|
||||
authenticate: true,
|
||||
imapAuthenticate: true,
|
||||
emailSend: true,
|
||||
} as const;
|
||||
|
||||
/** 로그인 기록 보존 — 90일 (날마다 정리) */
|
||||
export const LOGIN_EVENT_RETENTION_DAYS = 90;
|
||||
/**
|
||||
* 엔진 이벤트 기록을 한 문장에 넣는 행 수 — PostgreSQL 한 문장의 매개변수 상한(65,535)을 넘지 않게 나눈다.
|
||||
* 행마다 11칸이라 5,958행부터 넘는다(2026-10-31 리뷰) — 엔진은 실패한 묶음에 새 이벤트를 합쳐 다시 보내 묶음이 줄지 않는다
|
||||
*/
|
||||
export const LOGIN_EVENT_INSERT_CHUNK = 1_000;
|
||||
|
||||
/** 엔진 웹훅을 받는 경로 (글로벌 prefix 뒤) — 앞단 nginx 가 /api/internal/ 을 닫는다 (계약 5절 "엔진 웹훅 받기") */
|
||||
export const MAIL_ENGINE_EVENTS_ROUTE = 'internal/mail-engine-events';
|
||||
/** 이 경로만의 JSON 본문 한도 — 공격이 오면 엔진의 1초 묶음이 커진다 (계약 5절) */
|
||||
export const MAIL_ENGINE_EVENTS_JSON_LIMIT = '4mb';
|
||||
export const MAIL_ENGINE_EVENTS_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
maxArrayLength: 10_000,
|
||||
maxValues: 200_000,
|
||||
maxDepth: 32,
|
||||
};
|
||||
/** 엔진이 본문에 붙이는 서명 머리글 — 본문 원문의 HMAC-SHA256, base64 */
|
||||
export const MAIL_ENGINE_SIGNATURE_HEADER = 'x-signature';
|
||||
/**
|
||||
* 웹훅 거절의 warn 을 같은 까닭마다 이 시간에 한 번만 남긴다 — 서명 키가 어긋나면 엔진이 1초마다 묶음을 보내고 다시 보내
|
||||
* 거절마다 남기면 하루 수만 줄이 된다 (2026-10-31 개발 — 서명 키 없이 뜬 백엔드에 1초마다). 그사이 거절한 수는 다음 줄에 붙인다
|
||||
*/
|
||||
export const MAIL_ENGINE_REJECTION_LOG_WINDOW_MS = 60_000;
|
||||
|
||||
/** 앱 네트워크 대역의 기본값 — compose 의 APP_NETWORK_SUBNET 기본값과 같다 (docker 스킬) */
|
||||
export const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24';
|
||||
|
||||
/** 웹훅 서명 키 — 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자른다 — 계약 5절 "환경 변수") */
|
||||
export const MAIL_WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LoginAttemptModule } from '@/modules/auth/login-attempt.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider';
|
||||
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
buildAccountSecuritySettings,
|
||||
} from './account-security.settings';
|
||||
import { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsController } from './app-passwords.controller';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsCleanup } from './login-events.cleanup';
|
||||
import { LoginEventsController } from './login-events.controller';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsController } from './mail-engine-events.controller';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
import { TwoFactorController } from './two-factor.controller';
|
||||
import { TwoFactorService } from './two-factor.service';
|
||||
|
||||
function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
const settings = buildAccountSecuritySettings((key) =>
|
||||
config.get<string>(key),
|
||||
);
|
||||
if (settings.webhookSecret === null) {
|
||||
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다)
|
||||
new Logger('AccountSecurityModule').warn(
|
||||
'MAIL_WEBHOOK_SECRET 이 없어 메일 엔진 웹훅을 모두 401 로 거절합니다 — 메일 프로그램(IMAP · SMTP)의 로그인 기록이 남지 않습니다.',
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 보안 모듈 (12단계 — 계약 5절 "계정 보안") — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기.
|
||||
* 엔진을 바꿀 때는 ACCOUNT_SECURITY_PROVIDER 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다.
|
||||
* 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다.
|
||||
*
|
||||
* 인증 모듈이 로그인 · 비밀번호 바꾸기에 쓰도록 코드 한도 · 잠금 · 로그인 기록 · 앱 비밀번호를 내보낸다.
|
||||
* 이 모듈은 인증 모듈을 가져오지 않는다(인증 → 계정 보안 방향만) — 함께 쓰는 실패 카운터는 LoginAttemptModule 하나가 내놓는다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TwoFactorSettingEntity,
|
||||
AppPasswordUsageEntity,
|
||||
LoginEventEntity,
|
||||
]),
|
||||
MailEngineModule,
|
||||
// 지금 비밀번호 확인(verifyCredentials — 본인 Basic)
|
||||
MailModule,
|
||||
// 로그인 기록의 실패를 사내 목록으로 거른다
|
||||
ContactsModule,
|
||||
LoginAttemptModule,
|
||||
],
|
||||
controllers: [
|
||||
TwoFactorController,
|
||||
AppPasswordsController,
|
||||
LoginEventsController,
|
||||
MailEngineEventsController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: createSettings,
|
||||
},
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_PROVIDER,
|
||||
useClass: JmapAccountSecurityProvider,
|
||||
},
|
||||
TwoFactorService,
|
||||
TwoFactorEnrollments,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
LoginEventsCleanup,
|
||||
MailEngineEventsService,
|
||||
MailEngineEventsGuard,
|
||||
],
|
||||
exports: [
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
],
|
||||
})
|
||||
export class AccountSecurityModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 계정 보안 엔진 포트 (계약 5절 — 2단계 인증 · 앱 비밀번호) — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙).
|
||||
*
|
||||
* 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-account-security.provider.ts`(게이트웨이 대리 접근으로 x:AccountPassword · x:AppPassword).
|
||||
* 엔진을 바꿀 때는 이 인터페이스의 새 구현을 account-security.module.ts 에 연결한다.
|
||||
*
|
||||
* 규칙:
|
||||
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · id 형식을 밖으로 흘리지 않는다 — 앱 비밀번호 id 는 불투명 문자열)
|
||||
* - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 · 한도 REQ_001 · 계정을 쓸 수 없음 AUTH_001
|
||||
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 자격 증명에만 접근한다
|
||||
* - 비밀번호 · 코드 · 비밀(otpauth) · 앱 비밀번호 값 · 이름은 어떤 로그에도 남기지 않는다
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_PROVIDER = Symbol('ACCOUNT_SECURITY_PROVIDER');
|
||||
|
||||
/** 앱 비밀번호 하나 — 값은 만들 때만 (CreatedAppPassword) */
|
||||
export interface AppPasswordRecord {
|
||||
/** 엔진의 앱 비밀번호 id — 지운 번호를 엔진이 다시 쓴다 (createdAt 과 함께 봐야 같은 것이다) */
|
||||
readonly id: string;
|
||||
/** 이름 — 직원이 붙인 글 */
|
||||
readonly name: string;
|
||||
/** 만든 때 (ISO 8601 UTC) */
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값(password)은 이 한 번만 엔진이 준다 */
|
||||
export interface CreatedAppPassword extends AppPasswordRecord {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 켜기의 엔진 판단:
|
||||
* - enabled: 켰다
|
||||
* - refused: 엔진이 받지 않았다 — 지금 비밀번호가 맞지 않음 · 그사이 다른 곳에서 켜짐(지금 코드를 요구함)
|
||||
* - unknown: 결과를 알 수 없게 끝났다(시간 초과 · 연결 끊김 · 5xx) — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 끄기의 엔진 판단 — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - disabled: 껐다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
|
||||
|
||||
export interface AccountSecurityProvider {
|
||||
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
|
||||
isTwoFactorEnabled(user: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠다 — 지금 비밀번호와 otpauth 주소(서버가 만든 비밀). 엔진은 코드를 보지 않고 저장한다 —
|
||||
* 첫 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
enableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorEnableResult>;
|
||||
|
||||
/** 2단계 인증을 끈다 — 지금 비밀번호와 지금 코드 */
|
||||
disableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorDisableResult>;
|
||||
|
||||
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
|
||||
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
|
||||
|
||||
/**
|
||||
* 앱 비밀번호를 만든다 — 권한은 메일 프로그램만(로그인 · IMAP · 보내기), 만료 · 허용 IP 없음.
|
||||
* 엔진 상한을 넘으면 AUTH_009. 결과를 모르게 끝나면 MAIL_001 (값을 받지 못했으니 다시 만든다 — 목록에 남은 것은 직원이 지운다)
|
||||
*/
|
||||
createAppPassword(user: string, name: string): Promise<CreatedAppPassword>;
|
||||
|
||||
/** 앱 비밀번호 하나를 지운다 — 이미 없으면 그대로 끝난다 */
|
||||
deleteAppPassword(user: string, id: string): Promise<void>;
|
||||
|
||||
/** 그 직원의 앱 비밀번호를 모두 지운다 → 지운 수 (비밀번호를 바꿨을 때 — 계약 5절 "비밀번호 바꾸기") */
|
||||
deleteAllAppPasswords(user: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { buildAccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
const read =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
describe('buildAccountSecuritySettings (계약 5절 "환경 변수")', () => {
|
||||
it('서명 키 · 앱 네트워크 대역을 읽는다', () => {
|
||||
const settings = buildAccountSecuritySettings(
|
||||
read({
|
||||
MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a',
|
||||
APP_NETWORK_SUBNET: '10.20.0.0/16',
|
||||
}),
|
||||
);
|
||||
expect(settings.webhookSecret).toBe('Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a');
|
||||
expect(settings.appNetwork).toEqual({
|
||||
address: '10.20.0.0',
|
||||
prefix: 16,
|
||||
family: 4,
|
||||
});
|
||||
expect(settings.isAppNetworkAddress('10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('::ffff:10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(false);
|
||||
});
|
||||
|
||||
it('비거나 형식이 틀리면 서명 키 없음(웹훅을 모두 거절) · 대역은 compose 기본값 172.16.71.0/24', () => {
|
||||
const cases: Record<string, string>[] = [
|
||||
{},
|
||||
{ MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' },
|
||||
{ MAIL_WEBHOOK_SECRET: 'short', APP_NETWORK_SUBNET: 'not-a-cidr' },
|
||||
];
|
||||
for (const values of cases) {
|
||||
const settings = buildAccountSecuritySettings(read(values));
|
||||
expect(settings.webhookSecret).toBeNull();
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.72.2')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createCidrMatcher, parseCidr, type Cidr } from '@/shared/utils/cidr';
|
||||
import {
|
||||
DEFAULT_APP_NETWORK_SUBNET,
|
||||
MAIL_WEBHOOK_SECRET_PATTERN,
|
||||
} from './account-security.constants';
|
||||
|
||||
/**
|
||||
* 계정 보안 설정 (계약 5절 "환경 변수" · "엔진 웹훅 받기") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다.
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_SETTINGS = Symbol('ACCOUNT_SECURITY_SETTINGS');
|
||||
|
||||
export interface AccountSecuritySettings {
|
||||
/**
|
||||
* 엔진 웹훅 서명 키(`MAIL_WEBHOOK_SECRET` — setup 이 엔진의 x:WebHook 에 같은 값을 넣는다). 없거나 형식이 틀리면 null —
|
||||
* 웹훅을 모두 401 로 거절한다 (운영은 env 검증이 필수로 막는다)
|
||||
*/
|
||||
readonly webhookSecret: string | null;
|
||||
/** 엔진이 붙는 앱 네트워크 대역(`APP_NETWORK_SUBNET` — 없으면 compose 기본값 172.16.71.0/24) — 웹훅의 소켓 주소가 이 안이어야 한다 */
|
||||
readonly appNetwork: Cidr;
|
||||
/** 소켓 주소가 앱 네트워크 안인가 */
|
||||
readonly isAppNetworkAddress: (address: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다). 대역이 틀리면 기본값 (형식 오류는 env 검증이 기동 때 막는다) */
|
||||
export function buildAccountSecuritySettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): AccountSecuritySettings {
|
||||
const secret = read('MAIL_WEBHOOK_SECRET')?.trim() ?? '';
|
||||
const configured = read('APP_NETWORK_SUBNET')?.trim();
|
||||
const appNetwork =
|
||||
(configured ? parseCidr(configured) : null) ?? defaultAppNetwork();
|
||||
return {
|
||||
webhookSecret: MAIL_WEBHOOK_SECRET_PATTERN.test(secret) ? secret : null,
|
||||
appNetwork,
|
||||
isAppNetworkAddress: createCidrMatcher(appNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
/** compose 기본값의 대역 — 상수라 늘 읽힌다 */
|
||||
function defaultAppNetwork(): Cidr {
|
||||
const cidr = parseCidr(DEFAULT_APP_NETWORK_SUBNET);
|
||||
if (cidr === null) {
|
||||
throw new Error(
|
||||
`기본 앱 네트워크 대역(${DEFAULT_APP_NETWORK_SUBNET})을 읽지 못했습니다.`,
|
||||
);
|
||||
}
|
||||
return cidr;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 엔진의 JMAP id — 64비트 수의 32진수라 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 쓰인 곳 — imap · smtp */
|
||||
const METHOD_COLUMN_LENGTH = 8;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
|
||||
/** 앱 비밀번호가 쓰인 곳 (엔진 웹훅의 리스너) */
|
||||
export type AppPasswordUseMethod = 'imap' | 'smtp';
|
||||
|
||||
/**
|
||||
* 앱 비밀번호의 서버 기록 (계약 5절 "앱 비밀번호 — 마지막 사용") — 엔진에 마지막 사용 칸이 없어 웹훅의 auth.success(앱 비밀번호)로 만든다.
|
||||
* 엔진은 지운 번호를 다시 쓴다 — 그래서 **같은 번호 · 같은 createdAt** 일 때만 잇는다(목록이 엔진의 createdAt 과 견준다).
|
||||
* 만들 때 서버가 이 줄을 새로 둔다(웹메일 밖에서 만든 것은 목록을 볼 때). 이름은 그때의 이름이다 — 로그인 기록에 앱 비밀번호 이름을 남길 때 쓴다.
|
||||
* 값(비밀)은 두지 않는다
|
||||
*/
|
||||
@Entity({ name: 'app_password_usages' })
|
||||
export class AppPasswordUsageEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
})
|
||||
appPasswordId!: string;
|
||||
|
||||
/** 그때의 이름 (40자까지 — 엔진에서 더 긴 이름이면 잘라 둔다) */
|
||||
@Column({ type: 'varchar', length: APP_PASSWORD_NAME_MAX_LENGTH })
|
||||
name!: string;
|
||||
|
||||
/** 엔진이 알려 준 만든 때 — 같은 번호를 다시 쓴 앱 비밀번호와 가른다 */
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
|
||||
lastUsedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_method',
|
||||
type: 'varchar',
|
||||
length: METHOD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedMethod!: AppPasswordUseMethod | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_ip',
|
||||
type: 'varchar',
|
||||
length: IP_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedIp!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
APP_PASSWORD_ID_PATTERN,
|
||||
credentialNumberToId,
|
||||
isAppPasswordShape,
|
||||
} from './app-password';
|
||||
|
||||
describe('isAppPasswordShape — 엔진이 앱 비밀번호로 읽는 모양 (웹메일에서 거절한다)', () => {
|
||||
it('엔진이 만드는 값(app_ + 엔진 base32 36자)은 앱 비밀번호다', () => {
|
||||
expect(isAppPasswordShape('app_abcdefghijklmnopqrstuvwxyz0123791203')).toBe(
|
||||
true,
|
||||
);
|
||||
// 넷째 글자는 엔진이 건너뛴다 · 대문자 K-Z 도 엔진이 받는다(역표의 특이점)
|
||||
expect(isAppPasswordShape(`appX${'kLMNOPQRSTUVWXYZ'.repeat(2)}abcd`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['39자', `app_${'a'.repeat(35)}`],
|
||||
['41자', `app_${'a'.repeat(37)}`],
|
||||
['app 으로 시작하지 않음', `ap__${'a'.repeat(36)}`],
|
||||
['대문자 APP', `APP_${'a'.repeat(36)}`],
|
||||
['엔진이 받지 않는 글자(4 · 5 · 6 · 8)', `app_${'4568'.repeat(9)}`],
|
||||
['엔진이 받지 않는 대문자 A-J', `app_${'ABCDEFGHIJ'.repeat(3)}abcdef`],
|
||||
['기호', `app_${'a'.repeat(35)}!`],
|
||||
['보통 비밀번호', 'correct-horse-battery-staple'],
|
||||
])('%s 은 앱 비밀번호가 아니다', (_label, value) => {
|
||||
expect(isAppPasswordShape(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialNumberToId — 엔진의 자격 번호 → JMAP id (Stalwart Id::as_string)', () => {
|
||||
it.each([
|
||||
[0, 'a'],
|
||||
[1, 'b'],
|
||||
[2, 'c'],
|
||||
[25, 'z'],
|
||||
[26, '7'],
|
||||
[31, '3'],
|
||||
[32, 'ba'],
|
||||
[33, 'bb'],
|
||||
[1024, 'baa'],
|
||||
])('%d → %s', (value, id) => {
|
||||
expect(credentialNumberToId(value)).toBe(id);
|
||||
expect(APP_PASSWORD_ID_PATTERN.test(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('음이 아닌 안전한 정수가 아니면 null', () => {
|
||||
for (const value of [-1, 1.5, Number.NaN, '1', null, undefined, 2 ** 60]) {
|
||||
expect(credentialNumberToId(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 앱 비밀번호 판정 (순수 함수) — 계약 5절 "앱 비밀번호".
|
||||
*/
|
||||
|
||||
/**
|
||||
* 엔진이 앱 비밀번호로 읽는 모양 — `app` + 아무 글자 하나 + 엔진의 base32 글자 36자 (모두 40자).
|
||||
* 엔진(Stalwart 0.16.27 — `auth/credential.rs`)은 `app` 으로 시작하고 넷째 글자를 건너뛴 뒤 사용자 정의 base32
|
||||
* (소문자 a-z · 숫자 0 1 2 3 7 9, 그리고 역표의 특이점으로 대문자 K-Z)로 22바이트(자격 번호 4 + 비밀 18)를 읽을 수 있으면 앱 비밀번호로 본다.
|
||||
* 엔진이 만드는 값은 `app_` + 36자다. 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기에서 이 모양이면 엔진에 묻지 않고 거절한다 —
|
||||
* 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다. 우연히 이 모양인 진짜 비밀번호는 엔진도 앱 비밀번호로 읽어 어차피 실패한다.
|
||||
* 정규식은 길이가 고정이라 입력 길이에 비례해 끝난다
|
||||
*/
|
||||
const APP_PASSWORD_SHAPE = /^app[\s\S][a-zK-Z012379]{36}$/;
|
||||
|
||||
export function isAppPasswordShape(password: string): boolean {
|
||||
return password.length === 40 && APP_PASSWORD_SHAPE.test(password);
|
||||
}
|
||||
|
||||
/** 엔진의 JMAP id 글자 (Stalwart `utils::codec::base32_custom::BASE32_ALPHABET`) */
|
||||
const JMAP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
const JMAP_ID_RADIX = 32;
|
||||
|
||||
/**
|
||||
* 엔진의 자격 번호(웹훅 `data.id` — 앱 비밀번호로 들어온 성공 이벤트) → 앱 비밀번호의 JMAP id (`x:AppPassword` 의 id).
|
||||
* 엔진은 자격 번호를 그대로 JMAP id 로 적는다 — 위 글자로 쓴 32진수, 큰 자리부터(0 은 `a`, 1 은 `b` — Stalwart `types::id::Id::as_string`).
|
||||
* 음이 아닌 안전한 정수가 아니면 null
|
||||
*/
|
||||
export function credentialNumberToId(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
let rest = value;
|
||||
let id = '';
|
||||
do {
|
||||
id = JMAP_ID_ALPHABET[rest % JMAP_ID_RADIX] + id;
|
||||
rest = Math.floor(rest / JMAP_ID_RADIX);
|
||||
} while (rest > 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 id 의 모양 — 엔진 JMAP id 글자만, 짧게 (경로 매개변수 검사) */
|
||||
export const APP_PASSWORD_ID_PATTERN = /^[a-z0-9]{1,13}$/;
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
AppPasswordsService,
|
||||
type AppPasswordView,
|
||||
} from './app-passwords.service';
|
||||
import {
|
||||
AppPasswordPageDto,
|
||||
CreateAppPasswordDto,
|
||||
CreatedAppPasswordDto,
|
||||
type AppPasswordDto,
|
||||
} from './dto/app-password.dto';
|
||||
|
||||
function toDto(view: AppPasswordView): AppPasswordDto {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
createdAt: view.createdAt.toISOString(),
|
||||
lastUsed: view.lastUsed
|
||||
? {
|
||||
at: view.lastUsed.at.toISOString(),
|
||||
method: view.lastUsed.method,
|
||||
ip: view.lastUsed.ip,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 앱 비밀번호 (계약 5절 "앱 비밀번호") — 메일 프로그램(IMAP 993 · SMTP 465)에 넣는 비밀번호. 판단은 AppPasswordsService 가 한다
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('app-passwords')
|
||||
export class AppPasswordsController {
|
||||
constructor(private readonly appPasswords: AppPasswordsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 목록 — 최근에 만든 것이 위 (한 쪽)',
|
||||
description:
|
||||
'마지막 사용(lastUsed)은 서버가 엔진의 로그인 이벤트(웹훅)로 만든 기록이다 — 같은 번호 · 같은 만든 때인 것만 잇는다. 아직 쓰지 않았으면 null.',
|
||||
})
|
||||
@ApiStandardResponse(AppPasswordPageDto, { description: '목록' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async list(@CurrentUser() user: AuthenticatedUser) {
|
||||
const page = await this.appPasswords.list(user.email);
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 만들기 — 값은 이 응답에만',
|
||||
description:
|
||||
'권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기) — 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기에는 쓰이지 않는다. 만료 · 허용 IP 는 두지 않는다. ' +
|
||||
'값은 엔진이 만들어 이 응답에만 싣는다(서버 · 로그에 남지 않는다 — Cache-Control: private, no-store). 2단계 인증을 켜지 않은 직원도 만든다.',
|
||||
})
|
||||
@ApiStandardResponse(CreatedAppPasswordDto, {
|
||||
status: 201,
|
||||
description: '만든 앱 비밀번호 — password 는 다시 볼 수 없다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'이름이 비었거나 40자를 넘음 · 이미 있는 이름(앞뒤 공백을 지운 값이 같음) · 형식 오류',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_009',
|
||||
'엔진 상한(기본 5개)을 넘음 — 쓰지 않는 것을 지운 뒤 만든다',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 만들기가 처리 중(한 번에 하나)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(값을 받지 못했다 — 목록에 남은 것은 지우고 다시 만든다)',
|
||||
)
|
||||
async create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: CreateAppPasswordDto,
|
||||
): Promise<CreatedAppPasswordDto> {
|
||||
const created = await this.appPasswords.create(user.email, dto.name);
|
||||
return { ...toDto(created), password: created.password };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 지우기 — 이미 없으면 200',
|
||||
description:
|
||||
'이 비밀번호로 새로 연결하는 메일 프로그램은 바로 막힌다. 이미 열린 IMAP 연결은 엔진이 끊지 않는다(다음 연결부터 — 알려진 한계).',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '앱 비밀번호 id', example: 'b' })
|
||||
@ApiStandardResponse(undefined, { description: '지웠다 (data: null)' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
): Promise<null> {
|
||||
await this.appPasswords.remove(user.email, id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type {
|
||||
AccountSecurityProvider,
|
||||
AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import type { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const VALUE = 'app_abcdefghijklmnopqrstuvwxyz0123791203';
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('AppPasswordsService (앱 비밀번호 — 계약 5절)', () => {
|
||||
let records: AppPasswordRecord[];
|
||||
let usageRows: AppPasswordUsageEntity[];
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
| 'listAppPasswords'
|
||||
| 'createAppPassword'
|
||||
| 'deleteAppPassword'
|
||||
| 'deleteAllAppPasswords'
|
||||
>
|
||||
>;
|
||||
let usages: {
|
||||
findBy: jest.Mock;
|
||||
findOneBy: jest.Mock;
|
||||
upsert: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let service: AppPasswordsService;
|
||||
let logs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
records = [];
|
||||
usageRows = [];
|
||||
provider = {
|
||||
listAppPasswords: jest.fn<Promise<AppPasswordRecord[]>, [string]>(() =>
|
||||
Promise.resolve([...records]),
|
||||
),
|
||||
createAppPassword: jest.fn((_user: string, name: string) => {
|
||||
const created = {
|
||||
id: 'c',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:20:00.000Z',
|
||||
password: VALUE,
|
||||
};
|
||||
records.push({ id: 'c', name, createdAt: created.createdAt });
|
||||
return Promise.resolve(created);
|
||||
}),
|
||||
deleteAppPassword: jest.fn<Promise<void>, [string, string]>(() =>
|
||||
Promise.resolve(),
|
||||
),
|
||||
deleteAllAppPasswords: jest.fn<Promise<number>, [string]>(() =>
|
||||
Promise.resolve(records.length),
|
||||
),
|
||||
};
|
||||
usages = {
|
||||
findBy: jest.fn(() => Promise.resolve([...usageRows])),
|
||||
findOneBy: jest.fn(({ appPasswordId }: { appPasswordId: string }) =>
|
||||
Promise.resolve(
|
||||
usageRows.find((row) => row.appPasswordId === appPasswordId) ?? null,
|
||||
),
|
||||
),
|
||||
upsert: jest.fn(
|
||||
(rows: AppPasswordUsageEntity | AppPasswordUsageEntity[]) => {
|
||||
for (const row of Array.isArray(rows) ? rows : [rows]) {
|
||||
usageRows = usageRows.filter(
|
||||
(existing) => existing.appPasswordId !== row.appPasswordId,
|
||||
);
|
||||
usageRows.push({ ...row });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
createQueryBuilder: jest.fn(() => {
|
||||
const builder = {
|
||||
update: () => builder,
|
||||
set: () => builder,
|
||||
where: () => builder,
|
||||
execute: () => Promise.resolve({ affected: 1 }),
|
||||
};
|
||||
return builder;
|
||||
}),
|
||||
};
|
||||
service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
logs = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('만들기 — 앞뒤 공백을 지운 이름으로 만들고 값은 응답에만 준다, 기록을 새로 둔다 (로그에는 가린 주소 + id 만)', async () => {
|
||||
const created = await service.create(ARA, ' 회사 PC 아웃룩 ');
|
||||
|
||||
expect(provider.createAppPassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'회사 PC 아웃룩',
|
||||
);
|
||||
expect(created).toEqual({
|
||||
id: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsed: null,
|
||||
password: VALUE,
|
||||
});
|
||||
expect(usageRows).toEqual([
|
||||
expect.objectContaining({
|
||||
appPasswordId: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
]);
|
||||
const everything = logs.join('\n');
|
||||
expect(everything).toContain('a***@arasystem.test, id=c');
|
||||
expect(everything).not.toContain(VALUE);
|
||||
expect(everything).not.toContain('회사 PC 아웃룩');
|
||||
});
|
||||
|
||||
it('같은 이름(앞뒤 공백을 지운 값)이 있으면 400 VAL_001 — 엔진에 만들기를 보내지 않는다', async () => {
|
||||
records = [
|
||||
{
|
||||
id: 'b',
|
||||
name: '회사 PC 아웃룩 ',
|
||||
createdAt: '2026-10-30T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
expect((await failureOf(service.create(ARA, '회사 PC 아웃룩')))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(provider.createAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('엔진 상한의 거절(AUTH_009)은 그대로 던진다', async () => {
|
||||
provider.createAppPassword.mockRejectedValue(
|
||||
new BusinessException('AUTH_009'),
|
||||
);
|
||||
expect((await failureOf(service.create(ARA, '다섯째')))?.code).toBe(
|
||||
'AUTH_009',
|
||||
);
|
||||
});
|
||||
|
||||
it('목록 — 최근에 만든 것이 위, 같은 번호 · 같은 만든 때의 기록만 마지막 사용으로 잇는다', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: '집 PC', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: '휴대폰', createdAt: '2026-10-31T00:00:00.000Z' },
|
||||
];
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
lastUsedMethod: 'imap',
|
||||
lastUsedIp: '211.186.95.252',
|
||||
},
|
||||
// 엔진이 번호 c 를 다시 썼다 — 옛 기록(만든 때가 다르다)은 잇지 않는다
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'c',
|
||||
name: '지운 옛것',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-02T00:00:00.000Z'),
|
||||
lastUsedMethod: 'smtp',
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
|
||||
const page = await service.list(ARA);
|
||||
|
||||
expect(page.items.map(({ id }) => id)).toEqual(['c', 'b']);
|
||||
expect(page.items[0].lastUsed).toBeNull();
|
||||
expect(page.items[1].lastUsed).toEqual({
|
||||
at: new Date('2026-10-31T00:41:00.000Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
});
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 다른 앱 비밀번호의 기록은 새로 둔다(만든 때 · 이름 · 마지막 사용 없음)
|
||||
expect(usageRows.find((row) => row.appPasswordId === 'c')).toMatchObject({
|
||||
name: '휴대폰',
|
||||
createdAt: new Date('2026-10-31T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('지우기 — 모양이 아닌 id 는 엔진에 묻지 않고 그대로 끝난다', async () => {
|
||||
await service.remove(ARA, 'b');
|
||||
expect(provider.deleteAppPassword).toHaveBeenCalledWith(ARA, 'b');
|
||||
|
||||
provider.deleteAppPassword.mockClear();
|
||||
await service.remove(ARA, '../etc/passwd');
|
||||
await service.remove(ARA, 'B');
|
||||
expect(provider.deleteAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾼 뒤 모두 지운다 — 실패해도 던지지 않고 error 로그(가린 주소)', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: 'x', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: 'y', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
];
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(2);
|
||||
expect(usages.delete).toHaveBeenCalledWith({ userEmail: ARA });
|
||||
|
||||
provider.deleteAllAppPasswords.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(0);
|
||||
expect(logs.join('\n')).toMatch(
|
||||
/\[SYS_001\].*앱 비밀번호를 지우지 못했습니다.*a\*\*\*@arasystem\.test/,
|
||||
);
|
||||
});
|
||||
|
||||
it('웹훅의 사용 — 기록이 있으면 마지막 사용을 고치고 그때의 이름을 돌려준다, 없으면 null', async () => {
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
const use = { at: new Date(), method: 'imap' as const, ip: '1.2.3.4' };
|
||||
await expect(service.recordUse(ARA, 'b', use)).resolves.toBe('집 PC');
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
await expect(service.recordUse(ARA, 'z', use)).resolves.toBeNull();
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppPasswordsService.create — 같은 직원의 만들기는 한 번에 하나 (같은 이름 검사가 동시 요청에서 겹치지 않게)', () => {
|
||||
it('앞 만들기가 끝나기 전에 온 만들기는 429 REQ_001 — 끝나면 다시 된다, 다른 직원은 따로', async () => {
|
||||
let finish!: () => void;
|
||||
const provider = {
|
||||
listAppPasswords: jest.fn(() => Promise.resolve([])),
|
||||
createAppPassword: jest.fn(
|
||||
(_user: string, name: string) =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
id: 'b',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:00:00.000Z',
|
||||
password: `app_${'a'.repeat(36)}`,
|
||||
});
|
||||
}),
|
||||
),
|
||||
};
|
||||
const usages = { upsert: jest.fn(() => Promise.resolve()) };
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
|
||||
const first = service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('REQ_001');
|
||||
expect(provider.createAppPassword).toHaveBeenCalledTimes(1);
|
||||
|
||||
finish();
|
||||
await expect(first).resolves.toMatchObject({ id: 'b' });
|
||||
|
||||
provider.listAppPasswords.mockResolvedValueOnce([]);
|
||||
const again = service.create('ara.kim@arasystem.test', '휴대폰');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finish();
|
||||
await expect(again).resolves.toMatchObject({ name: '휴대폰' });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Not, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_PROVIDER,
|
||||
type AccountSecurityProvider,
|
||||
type AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import { APP_PASSWORD_ID_PATTERN } from './app-password';
|
||||
import {
|
||||
AppPasswordUsageEntity,
|
||||
type AppPasswordUseMethod,
|
||||
} from './app-password-usage.entity';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버 기록 */
|
||||
export interface AppPasswordLastUse {
|
||||
readonly at: Date;
|
||||
readonly method: AppPasswordUseMethod;
|
||||
readonly ip: string | null;
|
||||
}
|
||||
|
||||
/** 목록의 앱 비밀번호 하나 */
|
||||
export interface AppPasswordView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsed: AppPasswordLastUse | null;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값은 이 응답에만 */
|
||||
export interface CreatedAppPasswordView extends AppPasswordView {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 화면에 둘 이름 — 앞뒤 공백을 지운 값 (견줄 때도 이것으로) */
|
||||
function displayName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 비밀번호 (계약 5절 "앱 비밀번호") — 엔진의 x:AppPassword (대리 접근).
|
||||
*
|
||||
* - 값(password)은 엔진이 만들어 만들 때 한 번만 준다 — 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음)
|
||||
* - 권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기 — 어댑터가 늘 좁힌다). 만료 · 허용 IP 는 두지 않는다
|
||||
* - 같은 이름(앞뒤 공백을 지운 값이 같음)은 400 VAL_001, 엔진 상한(기본 5개)을 넘으면 409 AUTH_009.
|
||||
* 같은 직원의 만들기는 한 번에 하나다 — 처리 중이면 429 REQ_001 (동시 요청이 같은 이름 검사를 함께 지나가지 않게 — 2단계 잠금과 같은 결)
|
||||
* - 목록은 최근에 만든 것이 위다. 마지막 사용은 서버 기록(AppPasswordUsageEntity)에서 — 같은 번호 · 같은 createdAt 일 때만 잇는다
|
||||
* - 웹메일 비밀번호를 바꾸면 모두 지운다(removeAllAfterPasswordChange — 실패해도 던지지 않고 error 로그)
|
||||
* 이름(사용자 글) · 값은 로그에 남기지 않는다 — 만들기 · 지우기는 info 에 가린 주소 + id
|
||||
*
|
||||
* ⚠️ 만들기 잠금은 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppPasswordsService {
|
||||
private readonly logger = new Logger(AppPasswordsService.name);
|
||||
/** 만들기가 진행 중인 직원 주소 */
|
||||
private readonly creating = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(ACCOUNT_SECURITY_PROVIDER)
|
||||
private readonly provider: AccountSecurityProvider,
|
||||
@InjectRepository(AppPasswordUsageEntity)
|
||||
private readonly usages: Repository<AppPasswordUsageEntity>,
|
||||
) {}
|
||||
|
||||
/** GET /api/app-passwords — 한 쪽 (엔진 상한이 작다), total 포함 */
|
||||
async list(user: string): Promise<PaginatedResult<AppPasswordView>> {
|
||||
const address = normalizeEmail(user);
|
||||
const records = await this.provider.listAppPasswords(address);
|
||||
const items = await this.withUsage(address, records);
|
||||
return toPaginatedResult(items, {
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords — 같은 직원의 만들기가 처리 중이면 REQ_001 (확인과 차지 사이에 await 가 없다) */
|
||||
async create(user: string, name: string): Promise<CreatedAppPasswordView> {
|
||||
const address = normalizeEmail(user);
|
||||
if (this.creating.has(address)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'app-password-create-in-progress',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.creating.add(address);
|
||||
try {
|
||||
return await this.createOne(address, displayName(name));
|
||||
} finally {
|
||||
this.creating.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOne(
|
||||
address: string,
|
||||
trimmed: string,
|
||||
): Promise<CreatedAppPasswordView> {
|
||||
const existing = await this.provider.listAppPasswords(address);
|
||||
if (existing.some((record) => displayName(record.name) === trimmed)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'app-password-name-taken',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const created = await this.provider.createAppPassword(address, trimmed);
|
||||
const createdAt = new Date(created.createdAt);
|
||||
try {
|
||||
// 만들 때 기록을 새로 둔다 — 엔진이 지운 번호를 다시 썼으면 옛 기록(그때의 마지막 사용)을 덮는다
|
||||
await this.usages.upsert(
|
||||
{
|
||||
userEmail: address,
|
||||
appPasswordId: created.id,
|
||||
name: trimmed.slice(0, APP_PASSWORD_NAME_MAX_LENGTH),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
['userEmail', 'appPasswordId'],
|
||||
);
|
||||
} catch (error) {
|
||||
// 값은 이미 만들어졌다 — 실패로 답하면 직원이 값을 받지 못한다. 기록은 목록을 볼 때 다시 둔다
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 두지 못했습니다(앱 비밀번호는 만들었다): user=${maskEmail(address)}, id=${created.id}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 만들었습니다: ${maskEmail(address)}, id=${created.id}`,
|
||||
);
|
||||
return {
|
||||
id: created.id,
|
||||
name: trimmed,
|
||||
createdAt,
|
||||
lastUsed: null,
|
||||
password: created.password,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/app-passwords/{id} — 이미 없으면 그대로 끝난다.
|
||||
* 모양이 아닌 id 는 있을 수 없는 앱 비밀번호다 — 엔진에 묻지 않고 "이미 없음"과 같게 끝낸다
|
||||
*/
|
||||
async remove(user: string, id: string): Promise<void> {
|
||||
if (!APP_PASSWORD_ID_PATTERN.test(id)) return;
|
||||
const address = normalizeEmail(user);
|
||||
await this.provider.deleteAppPassword(address, id);
|
||||
await this.usages.delete({ userEmail: address, appPasswordId: id });
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 지웠습니다: ${maskEmail(address)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹메일 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") → 지운 수.
|
||||
* 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있다. 실패하면 error 로그(남은 앱 비밀번호가 있다 — 운영자가 지울 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
async removeAllAfterPasswordChange(user: string): Promise<number> {
|
||||
const address = normalizeEmail(user);
|
||||
try {
|
||||
const removed = await this.provider.deleteAllAppPasswords(address);
|
||||
await this.usages.delete({ userEmail: address });
|
||||
return removed;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 앱 비밀번호를 지우지 못했습니다 — 남은 앱 비밀번호는 메일 프로그램에서 계속 쓰입니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 앱 비밀번호 로그인 — 그 번호의 기록에 마지막 사용을 남긴다(지금 남은 것보다 늦을 때만 — 늦게 온 묶음이 되돌리지 않게) →
|
||||
* 그 기록의 이름(로그인 기록에 남긴다). 기록이 없으면(웹메일 밖에서 만들고 목록도 보지 않음) null
|
||||
*/
|
||||
async recordUse(
|
||||
user: string,
|
||||
id: string,
|
||||
use: AppPasswordLastUse,
|
||||
): Promise<string | null> {
|
||||
const address = normalizeEmail(user);
|
||||
const row = await this.usages.findOneBy({
|
||||
userEmail: address,
|
||||
appPasswordId: id,
|
||||
});
|
||||
if (!row) return null;
|
||||
await this.usages
|
||||
.createQueryBuilder()
|
||||
.update(AppPasswordUsageEntity)
|
||||
.set({
|
||||
lastUsedAt: use.at,
|
||||
lastUsedMethod: use.method,
|
||||
lastUsedIp: use.ip,
|
||||
})
|
||||
.where(
|
||||
'user_email = :address AND app_password_id = :id AND (last_used_at IS NULL OR last_used_at < :at)',
|
||||
{ address, id, at: use.at },
|
||||
)
|
||||
.execute();
|
||||
return row.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 목록 + 서버 기록 → 최근에 만든 것이 위. 같은 번호 · 같은 createdAt 인 기록만 잇고,
|
||||
* 기록이 없거나 다른 앱 비밀번호의 것이면(엔진이 번호를 다시 씀) 새로 둔다. 엔진에 없는 번호의 기록은 지운다
|
||||
*/
|
||||
private async withUsage(
|
||||
address: string,
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<AppPasswordView[]> {
|
||||
const rows = await this.usages.findBy({ userEmail: address });
|
||||
const byId = new Map(rows.map((row) => [row.appPasswordId, row]));
|
||||
const fresh: AppPasswordUsageEntity[] = [];
|
||||
|
||||
const items = records.map((record): AppPasswordView => {
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const row = byId.get(record.id);
|
||||
const same =
|
||||
row !== undefined && row.createdAt.getTime() === createdAt.getTime();
|
||||
if (!same) {
|
||||
fresh.push(
|
||||
Object.assign(new AppPasswordUsageEntity(), {
|
||||
userEmail: address,
|
||||
appPasswordId: record.id,
|
||||
name: displayName(record.name).slice(
|
||||
0,
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
name: displayName(record.name),
|
||||
createdAt,
|
||||
lastUsed:
|
||||
same && row.lastUsedAt && row.lastUsedMethod
|
||||
? {
|
||||
at: row.lastUsedAt,
|
||||
method: row.lastUsedMethod,
|
||||
ip: row.lastUsedIp,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
await this.saveUsageRows(address, fresh, records);
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||
b.id.localeCompare(a.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록을 본 김에 기록을 엔진에 맞춘다 — 실패해도 목록은 그대로 준다(마지막 사용이 빠질 뿐) */
|
||||
private async saveUsageRows(
|
||||
address: string,
|
||||
fresh: readonly AppPasswordUsageEntity[],
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (fresh.length > 0) {
|
||||
await this.usages.upsert([...fresh], ['userEmail', 'appPasswordId']);
|
||||
}
|
||||
const ids = records.map((record) => record.id);
|
||||
await this.usages.delete(
|
||||
ids.length > 0
|
||||
? { userEmail: address, appPasswordId: Not(In(ids)) }
|
||||
: { userEmail: address },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 엔진 목록에 맞추지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import { TrimString } from '@/common/decorators/trim-string.decorator';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from '@/modules/account-security/account-security.constants';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버가 엔진 웹훅으로 만든 기록 */
|
||||
export class AppPasswordLastUseDto {
|
||||
@ApiProperty({
|
||||
description: '마지막으로 쓴 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
at!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '쓴 곳 — imap(메일 받기) · smtp(메일 보내기)',
|
||||
enum: ['imap', 'smtp'],
|
||||
example: 'imap',
|
||||
})
|
||||
method!: 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 프로그램의 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
}
|
||||
|
||||
export class AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 id — 지우기에 쓴다 (엔진이 지운 번호를 다시 쓴다)',
|
||||
example: 'b',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '이름', example: '회사 PC 아웃룩' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만든 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:20:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 사용 — 아직 쓰지 않았으면 null',
|
||||
type: AppPasswordLastUseDto,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsed!: AppPasswordLastUseDto | null;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords → 201 — 값은 이 응답에만 */
|
||||
export class CreatedAppPasswordDto extends AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 값 — 엔진이 만든 40자(`app_` + 36자). 이 응답에만 있고 다시 볼 수 없다(서버는 두지 않는다)',
|
||||
example: 'app_abcdefghijklmnopqrstuvwxyz0123791203',
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class AppPasswordPageDto {
|
||||
@ApiProperty({
|
||||
type: [AppPasswordDto],
|
||||
description: '최근에 만든 것이 위 — 한 쪽에 모두 (엔진 상한이 작다)',
|
||||
})
|
||||
items!: AppPasswordDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'hasNext false · nextCursor null · total 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords */
|
||||
export class CreateAppPasswordDto {
|
||||
@ApiProperty({
|
||||
description: `이름 — 1~${APP_PASSWORD_NAME_MAX_LENGTH}자(앞뒤 공백을 지운 값, JS 문자열 길이). 이미 있는 이름이면 400 VAL_001`,
|
||||
minLength: 1,
|
||||
maxLength: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
@applyDecorators(
|
||||
TrimString(),
|
||||
IsString({ message: '이름을 넣어 주세요.' }),
|
||||
MinCodeUnits(1, { message: '이름을 넣어 주세요.' }),
|
||||
MaxCodeUnits(APP_PASSWORD_NAME_MAX_LENGTH, {
|
||||
message: `이름은 ${APP_PASSWORD_NAME_MAX_LENGTH}자 이하로 넣어 주세요.`,
|
||||
}),
|
||||
)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PageInfoDto,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
|
||||
/** 쿼리의 'true' · 'false' → 불리언 (그 밖의 값은 그대로 두어 IsBoolean 이 VAL_001 로 막는다) */
|
||||
function toBooleanQuery({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** GET /api/login-events 의 쿼리 — 커서 · 개수(기본 20 · 최대 100) · 성공만/실패만 */
|
||||
export class LoginEventQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'false 면 실패만, true 면 성공만 — 없으면 전체',
|
||||
enum: ['true', 'false'],
|
||||
example: 'false',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBooleanQuery)
|
||||
@IsBoolean({ message: 'isSuccess 는 true 또는 false 여야 합니다.' })
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
/** 브라우저 · 운영체제의 짧은 이름 (shared/utils/user-agent.ts) — 모르면 null */
|
||||
export class DeviceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Chrome',
|
||||
})
|
||||
browser!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Windows · macOS · iOS · Android · Linux · ChromeOS — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Windows',
|
||||
})
|
||||
os!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventAppPasswordDto {
|
||||
@ApiProperty({ description: '앱 비밀번호 id', example: 'b' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그때의 이름 (지운 뒤에도 남는다) — 서버 기록이 없으면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
name!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventDto {
|
||||
@ApiProperty({ description: '기록 id', example: '1042' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '일어난 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
occurredAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'webmail · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
|
||||
enum: ['webmail', 'imap', 'smtp'],
|
||||
example: 'webmail',
|
||||
})
|
||||
method!: 'webmail' | 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({ description: '성공했는가', example: true })
|
||||
isSuccess!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
|
||||
example: true,
|
||||
})
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'실패 까닭 — wrongPassword(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · wrongCode(코드 틀림 · 다시 쓴 코드) · ' +
|
||||
'appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로). 성공이면 null',
|
||||
type: String,
|
||||
enum: ['wrongPassword', 'wrongCode', 'appPasswordRequired'],
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
failureReason!: 'wrongPassword' | 'wrongCode' | 'appPasswordRequired' | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '클라이언트 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일의 브라우저 · 운영체제 — 메일 프로그램이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
device!: DeviceDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일 프로그램이 앱 비밀번호로 들어왔으면 그 앱 비밀번호 — 아니면 null',
|
||||
type: LoginEventAppPasswordDto,
|
||||
nullable: true,
|
||||
})
|
||||
appPassword!: LoginEventAppPasswordDto | null;
|
||||
}
|
||||
|
||||
export class LoginEventPageDto {
|
||||
@ApiProperty({ type: [LoginEventDto], description: '최근 차례' })
|
||||
items!: LoginEventDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'total — 최근 90일 · 거른 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
CurrentPasswordField,
|
||||
OtpCodeField,
|
||||
} from '@/modules/auth/dto/security-fields';
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
|
||||
/** GET /api/two-factor — 켜기 · 끄기의 응답도 같다 (계약 5절 "2단계 인증") */
|
||||
export class TwoFactorStatusDto {
|
||||
@ApiProperty({
|
||||
description: '엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다',
|
||||
example: true,
|
||||
})
|
||||
isEnabled!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일에서 켠 때 (ISO 8601 UTC) — 꺼져 있거나 엔진 관리 화면에서 켰으면 null',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-10-31T00:12:00.000Z',
|
||||
})
|
||||
enabledAt!: string | null;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */
|
||||
export class TwoFactorEnrollmentDto {
|
||||
@ApiProperty({
|
||||
description: '등록 id — 켜기(activation)에 그대로 보낸다',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'비밀 — 160비트를 base32(RFC 4648, 채움 없음)로 적은 32자. QR 을 찍을 수 없을 때 직원이 직접 넣는다. 이 응답에만 있다(서버 · 로그에 남지 않는다)',
|
||||
example: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP',
|
||||
})
|
||||
secret!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 넣을 otpauth 주소 — 화면이 이 주소로 QR 을 그린다. 발급자 「ARA SYSTEM」 · SHA1 · 6자리 · 30초',
|
||||
example:
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30',
|
||||
})
|
||||
otpauthUrl!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이때까지 켠다 (등록을 시작하고 10분) — 지나면 409 AUTH_008',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:22:00.000Z',
|
||||
})
|
||||
expiresAt!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
export class TwoFactorActivationDto {
|
||||
@ApiProperty({
|
||||
description: '등록 응답의 enrollmentId',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 보이는 숫자 6자리 — 등록의 비밀로 서버가 먼저 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 — 엔진이 2단계 인증을 켤 때 요구한다 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation */
|
||||
export class TwoFactorDeactivationDto {
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
describe('로그인 기록의 커서 (자리 — 일어난 때 · 차례 번호)', () => {
|
||||
const position = {
|
||||
occurredAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
id: '1042',
|
||||
};
|
||||
|
||||
it('같은 거르기로 읽으면 자리가 그대로 나온다', () => {
|
||||
const scope = loginEventScope(false);
|
||||
const cursor = encodeLoginEventCursor(position, scope);
|
||||
expect(decodeLoginEventCursor(cursor, scope)).toEqual({ position });
|
||||
});
|
||||
|
||||
it('다른 거르기(전체 · 성공만 · 실패만)의 커서는 받지 않는다', () => {
|
||||
const cursor = encodeLoginEventCursor(position, loginEventScope(undefined));
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(false))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(true))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
});
|
||||
|
||||
it('모양이 틀리면 까닭과 함께 거절한다', () => {
|
||||
const scope = loginEventScope(undefined);
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
expect(decodeLoginEventCursor('a'.repeat(300), scope)).toEqual({
|
||||
problem: 'too-long',
|
||||
});
|
||||
expect(decodeLoginEventCursor('@@@', scope)).toEqual({
|
||||
problem: 'not-json',
|
||||
});
|
||||
for (const bad of [
|
||||
{ v: 2, t: position.occurredAt.toISOString(), i: '1', s: 'x' },
|
||||
{ v: 1, t: 'not-a-date', i: '1', s: 'x' },
|
||||
{ v: 1, t: '2026-10-31T00:41:00Z', i: '1', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '0', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '1; DROP', s: 'x' },
|
||||
]) {
|
||||
expect(decodeLoginEventCursor(encode(bad), scope)).toEqual({
|
||||
problem: 'malformed',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* 로그인 기록의 커서 (순수 함수) — 최근 차례(일어난 때 · 차례 번호의 내림차순)에서 마지막으로 준 기록의 자리.
|
||||
* 위치(offset)가 아니라 자리(keyset)를 적는다 — 새 기록은 맨 위에 쌓이므로 위치 커서면 다음 쪽이 밀려 겹친다.
|
||||
* 다른 거르기(isSuccess)의 커서를 섞어 쓰지 못하게 범위의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts 와 같은 결).
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 256;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** 차례 번호 — bigint 의 10진 표기 */
|
||||
const ID_PATTERN = /^[1-9]\d{0,18}$/;
|
||||
|
||||
/** 마지막으로 준 기록의 자리 */
|
||||
export interface LoginEventPosition {
|
||||
readonly occurredAt: Date;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type LoginEventCursorReading =
|
||||
| { readonly position: LoginEventPosition }
|
||||
| { readonly problem: 'too-long' | 'not-json' | 'malformed' | 'other-query' };
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
t: string;
|
||||
i: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
/** 커서의 범위 — 거르기 값 (전체 · 성공만 · 실패만) */
|
||||
export function loginEventScope(isSuccess: boolean | undefined): string {
|
||||
return isSuccess === undefined ? 'all' : String(isSuccess);
|
||||
}
|
||||
|
||||
function scopeHash(scope: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`login-events:${scope}`)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
export function encodeLoginEventCursor(
|
||||
position: LoginEventPosition,
|
||||
scope: string,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
t: position.occurredAt.toISOString(),
|
||||
i: position.id,
|
||||
s: scopeHash(scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */
|
||||
export function decodeLoginEventCursor(
|
||||
cursor: string,
|
||||
scope: string,
|
||||
): LoginEventCursorReading {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return { problem: 'not-json' };
|
||||
}
|
||||
const { v, t, i, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
if (
|
||||
v !== CURSOR_VERSION ||
|
||||
typeof t !== 'string' ||
|
||||
typeof i !== 'string' ||
|
||||
typeof s !== 'string' ||
|
||||
!ID_PATTERN.test(i)
|
||||
) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
const occurredAt = new Date(t);
|
||||
if (Number.isNaN(occurredAt.getTime()) || occurredAt.toISOString() !== t) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
if (s !== scopeHash(scope)) return { problem: 'other-query' };
|
||||
return { position: { occurredAt, id: i } };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 방식 · 실패 까닭 같은 짧은 낱말 */
|
||||
const WORD_COLUMN_LENGTH = 32;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
/** User-Agent 원문 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
/** 엔진의 JMAP id — 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */
|
||||
const DEDUPE_KEY_LENGTH = 43;
|
||||
|
||||
/** 로그인 방식 — 웹메일 · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
|
||||
export type LoginEventMethod = 'webmail' | 'imap' | 'smtp';
|
||||
|
||||
/**
|
||||
* 실패 까닭 (계약 5절 "로그인 기록"):
|
||||
* - wrongPassword: 비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함
|
||||
* - wrongCode: 2단계 인증 코드 틀림 · 다시 쓴 코드
|
||||
* - appPasswordRequired: 2단계를 켠 계정에 메일 프로그램이 비밀번호로 들어오려 함 (엔진 auth.mfa-required)
|
||||
*/
|
||||
export type LoginFailureReason =
|
||||
| 'wrongPassword'
|
||||
| 'wrongCode'
|
||||
| 'appPasswordRequired';
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts).
|
||||
* - 웹메일: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
* - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다)
|
||||
*/
|
||||
@Entity({ name: 'login_events' })
|
||||
@Index('IDX_login_events_user_occurred', ['userEmail', 'occurredAt', 'id'])
|
||||
export class LoginEventEntity {
|
||||
/** 차례 번호 — 응답에서는 문자열 (pg 는 bigint 를 문자열로 준다) */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 일어난 때 — 웹메일은 서버가 판정한 때, 메일 프로그램은 엔진 이벤트의 createdAt(초 단위) */
|
||||
@Index('IDX_login_events_occurred_at')
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
method!: LoginEventMethod;
|
||||
|
||||
@Column({ name: 'is_success', type: 'boolean' })
|
||||
isSuccess!: boolean;
|
||||
|
||||
/** 웹메일 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
|
||||
@Column({ name: 'is_two_factor', type: 'boolean' })
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'failure_reason',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
failureReason!: LoginFailureReason | null;
|
||||
|
||||
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
|
||||
ip!: string | null;
|
||||
|
||||
/** 웹메일의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
length: USER_AGENT_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
userAgent!: string | null;
|
||||
|
||||
/** 메일 프로그램이 앱 비밀번호로 들어왔으면 그 id */
|
||||
@Column({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordId!: string | null;
|
||||
|
||||
/** 그때의 앱 비밀번호 이름 — 서버 기록이 없으면 null */
|
||||
@Column({
|
||||
name: 'app_password_name',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordName!: string | null;
|
||||
|
||||
/** 엔진 이벤트의 겹침 거르기 키 — 이벤트 내용의 지문 (웹메일 기록은 null). 엔진이 다시 보내면 이 키가 같다 */
|
||||
@Index('UQ_login_events_dedupe_key', { unique: true })
|
||||
@Column({
|
||||
name: 'dedupe_key',
|
||||
type: 'char',
|
||||
length: DEDUPE_KEY_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
dedupeKey!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
/**
|
||||
* 로그인 기록 정리 — 90일이 지난 기록을 날마다 지운다 (계약 5절 "로그인 기록").
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsCleanup {
|
||||
private readonly logger = new Logger(LoginEventsCleanup.name);
|
||||
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, { name: 'login-events-cleanup' })
|
||||
async purgeOldEvents(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.loginEvents.purge(now);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`90일이 지난 로그인 기록 ${removed}건을 지웠습니다.`);
|
||||
}
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 날 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 로그인 기록 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { describeUserAgent } from '@/shared/utils/user-agent';
|
||||
import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants';
|
||||
import {
|
||||
LoginEventPageDto,
|
||||
LoginEventQueryDto,
|
||||
type LoginEventDto,
|
||||
} from './dto/login-event.dto';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
function toDto(event: LoginEventEntity): LoginEventDto {
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
method: event.method,
|
||||
isSuccess: event.isSuccess,
|
||||
isTwoFactor: event.isTwoFactor,
|
||||
failureReason: event.failureReason,
|
||||
ip: event.ip,
|
||||
device:
|
||||
event.method === 'webmail' ? describeUserAgent(event.userAgent) : null,
|
||||
appPassword: event.appPasswordId
|
||||
? { id: event.appPasswordId, name: event.appPasswordName }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일과 메일 프로그램(IMAP · SMTP)의 로그인
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('login-events')
|
||||
export class LoginEventsController {
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`,
|
||||
description:
|
||||
'웹메일: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
|
||||
'메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' +
|
||||
`기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`,
|
||||
})
|
||||
@ApiStandardResponse(LoginEventPageDto, { description: '한 쪽' })
|
||||
@ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 거르기의 커서')
|
||||
async list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: LoginEventQueryDto,
|
||||
): Promise<LoginEventPageDto> {
|
||||
const page = await this.loginEvents.list(user.email, {
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { LOGIN_EVENT_INSERT_CHUNK } from './account-security.constants';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService, retentionStart } from './login-events.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-31T03:00:00.000Z');
|
||||
|
||||
describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계약 5절)', () => {
|
||||
let events: { insert: jest.Mock };
|
||||
let directory: { hasEmail: jest.Mock };
|
||||
let service: LoginEventsService;
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
events = { insert: jest.fn().mockResolvedValue(undefined) };
|
||||
directory = { hasEmail: jest.fn().mockResolvedValue(true) };
|
||||
service = new LoginEventsService(
|
||||
events as unknown as Repository<LoginEventEntity>,
|
||||
directory as unknown as DirectoryService,
|
||||
);
|
||||
errors = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const attempt = (
|
||||
overrides: Partial<Parameters<LoginEventsService['recordWebmail']>[0]>,
|
||||
) => ({
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
failureReason: 'wrongPassword' as const,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => {
|
||||
await service.recordWebmail(
|
||||
attempt({
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
userAgent: `UA ${'x'.repeat(600)}`,
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
await service.recordWebmail(attempt({ failureReason: 'wrongCode' }), NOW);
|
||||
|
||||
expect(directory.hasEmail).not.toHaveBeenCalled();
|
||||
expect(events.insert).toHaveBeenCalledTimes(2);
|
||||
const [first] = events.insert.mock.calls[0] as [LoginEventEntity];
|
||||
expect(first).toMatchObject({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
dedupeKey: null,
|
||||
});
|
||||
expect(first.userAgent).toHaveLength(512);
|
||||
});
|
||||
|
||||
it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => {
|
||||
directory.hasEmail.mockResolvedValueOnce(false);
|
||||
await service.recordWebmail(
|
||||
attempt({ email: 'nobody@arasystem.test' }),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).not.toHaveBeenCalled();
|
||||
|
||||
directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
await service.recordWebmail(attempt({}), NOW);
|
||||
expect(events.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => {
|
||||
events.insert.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.recordWebmail(
|
||||
attempt({ isSuccess: true, failureReason: null }),
|
||||
NOW,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(errors.join('\n')).toMatch(/\[SYS_001\].*a\*\*\*@arasystem\.test/);
|
||||
expect(errors.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('보존 기간은 90일', () => {
|
||||
expect(retentionStart(NOW)).toEqual(new Date('2026-08-02T03:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginEventsService.insertEngineEvents (엔진 이벤트 넣기 — 문장마다 1,000행)', () => {
|
||||
/** createQueryBuilder().insert().into().values().orIgnore().returning().execute() 대역 — 문장마다 넣은 행을 모은다 */
|
||||
function fakeEvents(existing: ReadonlySet<string> = new Set()) {
|
||||
const statements: { dedupeKey: string | null }[][] = [];
|
||||
const builder = {
|
||||
rows: [] as { dedupeKey: string | null }[],
|
||||
insert: () => builder,
|
||||
into: () => builder,
|
||||
values: (rows: { dedupeKey: string | null }[]) => {
|
||||
builder.rows = rows;
|
||||
return builder;
|
||||
},
|
||||
orIgnore: () => builder,
|
||||
returning: () => builder,
|
||||
execute: () => {
|
||||
statements.push(builder.rows);
|
||||
// ON CONFLICT DO NOTHING — 이미 있는 키는 돌려주지 않는다
|
||||
return Promise.resolve({
|
||||
raw: builder.rows
|
||||
.filter((row) => row.dedupeKey && !existing.has(row.dedupeKey))
|
||||
.map((row) => ({ dedupe_key: row.dedupeKey })),
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
statements,
|
||||
repository: { createQueryBuilder: () => builder },
|
||||
};
|
||||
}
|
||||
|
||||
const rowsOf = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'imap' as const,
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: `key-${index}`,
|
||||
}));
|
||||
|
||||
it(`한 문장에 ${LOGIN_EVENT_INSERT_CHUNK}행까지 — 6,000행(행마다 11칸이면 한 문장의 매개변수 상한 65,535 를 넘는다)은 6문장으로 나눠 넣고 새 키를 모두 돌려준다`, async () => {
|
||||
const { statements, repository } = fakeEvents(
|
||||
new Set(['key-10', 'key-5999']),
|
||||
);
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
|
||||
const inserted = await service.insertEngineEvents(rowsOf(6_000));
|
||||
|
||||
expect(statements.map((rows) => rows.length)).toEqual([
|
||||
1000, 1000, 1000, 1000, 1000, 1000,
|
||||
]);
|
||||
expect(
|
||||
Math.max(...statements.map((rows) => rows.length * 11)),
|
||||
).toBeLessThan(65_535);
|
||||
expect(inserted.size).toBe(5_998);
|
||||
expect(inserted.has('key-10')).toBe(false);
|
||||
expect(inserted.has('key-1000')).toBe(true);
|
||||
});
|
||||
|
||||
it('남은 행만큼 마지막 문장 · 빈 묶음은 DB 에 묻지 않는다', async () => {
|
||||
const { statements, repository } = fakeEvents();
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
await service.insertEngineEvents(rowsOf(2_500));
|
||||
expect(statements.map((rows) => rows.length)).toEqual([1000, 1000, 500]);
|
||||
|
||||
statements.length = 0;
|
||||
await expect(service.insertEngineEvents([])).resolves.toEqual(new Set());
|
||||
expect(statements).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
import {
|
||||
LOGIN_EVENT_INSERT_CHUNK,
|
||||
LOGIN_EVENT_RETENTION_DAYS,
|
||||
} from './account-security.constants';
|
||||
import {
|
||||
LoginEventEntity,
|
||||
type LoginFailureReason,
|
||||
} from './login-event.entity';
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 웹메일 로그인 시도 하나 — 서버가 판정한 것 */
|
||||
export interface WebmailLoginAttempt {
|
||||
readonly email: string;
|
||||
readonly isSuccess: boolean;
|
||||
/** 성공이 코드까지 거쳤는가 */
|
||||
readonly isTwoFactor: boolean;
|
||||
readonly failureReason: LoginFailureReason | null;
|
||||
readonly ip: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
/** 로그인 기록 조회 — 커서 · 개수 · 성공만/실패만 */
|
||||
export interface LoginEventQuery {
|
||||
readonly cursor?: string;
|
||||
readonly limit: number;
|
||||
readonly isSuccess?: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 보존 기간의 시작 — 지금부터 90일 전 */
|
||||
export function retentionStart(now: Date): Date {
|
||||
return new Date(now.getTime() - LOGIN_EVENT_RETENTION_DAYS * DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
|
||||
*
|
||||
* - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도
|
||||
* 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다
|
||||
* - 기록에 실패해도 로그인은 그대로 간다 (error 로그만)
|
||||
* - 목록은 최근 90일 · 최근 차례 · 커서(자리) — 성공만 · 실패만으로 거를 수 있다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만)
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsService {
|
||||
private readonly logger = new Logger(LoginEventsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LoginEventEntity)
|
||||
private readonly events: Repository<LoginEventEntity>,
|
||||
private readonly directory: DirectoryService,
|
||||
) {}
|
||||
|
||||
/** 웹메일 로그인 시도를 남긴다 — 던지지 않는다 */
|
||||
async recordWebmail(
|
||||
attempt: WebmailLoginAttempt,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(attempt.email);
|
||||
try {
|
||||
const keep =
|
||||
attempt.isSuccess ||
|
||||
attempt.failureReason === 'wrongCode' ||
|
||||
(await this.isKnownEmployee(address));
|
||||
if (!keep) return;
|
||||
await this.events.insert({
|
||||
userEmail: address,
|
||||
occurredAt: now,
|
||||
method: 'webmail',
|
||||
isSuccess: attempt.isSuccess,
|
||||
isTwoFactor: attempt.isTwoFactor,
|
||||
failureReason: attempt.failureReason,
|
||||
ip: attempt.ip,
|
||||
userAgent: truncateUserAgent(attempt.userAgent),
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 웹메일 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 이벤트의 기록들을 넣는다 — 같은 겹침 거르기 키가 이미 있으면 건너뛴다(ON CONFLICT DO NOTHING) → 새로 넣은 키.
|
||||
* 한 문장의 매개변수 상한 때문에 1,000행씩 나눠 넣는다(LOGIN_EVENT_INSERT_CHUNK) — 문장마다 따로 끝나도 다시 받으면 키가 같아 겹치지 않는다.
|
||||
* DB 실패는 던진다 — 웹훅이 5xx 로 답해 엔진이 다시 보낸다
|
||||
*/
|
||||
async insertEngineEvents(
|
||||
rows: readonly Omit<LoginEventEntity, 'id'>[],
|
||||
): Promise<Set<string>> {
|
||||
const inserted = new Set<string>();
|
||||
for (
|
||||
let start = 0;
|
||||
start < rows.length;
|
||||
start += LOGIN_EVENT_INSERT_CHUNK
|
||||
) {
|
||||
const result = await this.events
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(LoginEventEntity)
|
||||
.values(rows.slice(start, start + LOGIN_EVENT_INSERT_CHUNK))
|
||||
.orIgnore()
|
||||
.returning(['dedupe_key'])
|
||||
.execute();
|
||||
const raw: unknown = result.raw;
|
||||
if (!Array.isArray(raw)) continue;
|
||||
for (const row of raw as { dedupe_key?: unknown }[]) {
|
||||
if (typeof row.dedupe_key === 'string') inserted.add(row.dedupe_key);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/** 실패를 남길 주소인가 — 사내 목록에 있는 주소. 사내 목록을 읽지 못하면 남긴다 */
|
||||
async isKnownEmployee(address: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.directory.hasEmail(address);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/login-events — 최근 90일 · 최근 차례 */
|
||||
async list(
|
||||
user: string,
|
||||
query: LoginEventQuery,
|
||||
now: Date = new Date(),
|
||||
): Promise<PaginatedResult<LoginEventEntity>> {
|
||||
const address = normalizeEmail(user);
|
||||
const scope = loginEventScope(query.isSuccess);
|
||||
const position = query.cursor ? this.readCursor(query.cursor, scope) : null;
|
||||
|
||||
const base = this.events
|
||||
.createQueryBuilder('event')
|
||||
.where('event.user_email = :address', { address })
|
||||
.andWhere('event.occurred_at >= :since', { since: retentionStart(now) });
|
||||
if (query.isSuccess !== undefined) {
|
||||
base.andWhere('event.is_success = :isSuccess', {
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
}
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy('event.occurred_at', 'DESC')
|
||||
.addOrderBy('event.id', 'DESC')
|
||||
.limit(query.limit + 1);
|
||||
if (position) {
|
||||
page.andWhere(
|
||||
'(event.occurred_at < :at OR (event.occurred_at = :at AND event.id < :id))',
|
||||
{ at: position.occurredAt, id: position.id },
|
||||
);
|
||||
}
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = hasNext ? rows.slice(0, query.limit) : rows;
|
||||
const last = items[items.length - 1];
|
||||
return toPaginatedResult(items, {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeLoginEventCursor(
|
||||
{ occurredAt: last.occurredAt, id: last.id },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 90일이 지난 기록을 지운다 → 지운 수 */
|
||||
async purge(now: Date = new Date()): Promise<number> {
|
||||
const result = await this.events.delete({
|
||||
occurredAt: LessThan(retentionStart(now)),
|
||||
});
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
private readCursor(cursor: string, scope: string) {
|
||||
const reading = decodeLoginEventCursor(cursor, scope);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-login-event-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
return reading.position;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
addressOfAccountName,
|
||||
engineEventFingerprint,
|
||||
failureReasonOf,
|
||||
methodOfListener,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
const RECEIVED = new Date('2026-10-31T00:00:10.000Z');
|
||||
|
||||
/** 엔진이 보낸 모양 그대로 (2026-10-31 개발 엔진 웹훅 — 값은 바꿈) */
|
||||
const imapSuccess = {
|
||||
id: '17619051050001auth.success',
|
||||
createdAt: '2026-10-31T00:00:05Z',
|
||||
type: 'auth.success',
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
localPort: 993,
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
accountId: 4,
|
||||
spanId: 12345,
|
||||
},
|
||||
};
|
||||
|
||||
describe('readEngineEvents (엔진 웹훅 본문 읽기)', () => {
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 읽는다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
imapSuccess,
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'submissions',
|
||||
id: 'a1',
|
||||
reason: 'Authentication failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.mfa-required',
|
||||
data: { ...imapSuccess.data, listenerId: 'smtp' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
|
||||
expect(reading.malformed).toBe(false);
|
||||
expect(reading.skipped).toBe(0);
|
||||
expect(
|
||||
reading.events.map(({ method, outcome }) => [method, outcome]),
|
||||
).toEqual([
|
||||
['imap', 'success'],
|
||||
['smtp', 'failed'],
|
||||
['smtp', 'mfa-required'],
|
||||
]);
|
||||
expect(reading.events[0]).toMatchObject({
|
||||
occurredAt: new Date('2026-10-31T00:00:05Z'),
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
remoteIp: '211.186.95.252',
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('빈 묶음 {"events":[]} 은 정상이다 (setup 이 서명한 빈 묶음으로 받는 쪽을 확인한다)', () => {
|
||||
expect(readEngineEvents({ events: [] }, RECEIVED)).toEqual({
|
||||
events: [],
|
||||
skipped: 0,
|
||||
malformed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 자격 번호를 앱 비밀번호 id 로 — details 가 앱 비밀번호 표시일 때만 (IMAP 실패의 id 는 명령 태그다)', () => {
|
||||
const appPassword = {
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
},
|
||||
};
|
||||
const failedWithTag = {
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: { ...imapSuccess.data, id: 'a1' },
|
||||
};
|
||||
const [success, failed] = readEngineEvents(
|
||||
{ events: [appPassword, failedWithTag] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
|
||||
expect(success).toMatchObject({
|
||||
accountName: 'doyun.lee',
|
||||
appPasswordId: 'b',
|
||||
});
|
||||
expect(failed.appPasswordId).toBeNull();
|
||||
});
|
||||
|
||||
it('다른 종류 · http 리스너(웹메일 — 서버가 따로 남긴다) · 모양이 틀린 이벤트는 버린다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, type: 'auth.too-many-attempts' },
|
||||
{
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'http',
|
||||
details: 'mail-gateway@arasystem.test',
|
||||
},
|
||||
},
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, accountName: '' } },
|
||||
{ ...imapSuccess, data: 'oops' },
|
||||
'not-an-object',
|
||||
null,
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
expect(reading.events).toEqual([]);
|
||||
expect(reading.skipped).toBe(6);
|
||||
});
|
||||
|
||||
it('본문이 {"events":[…]} 모양이 아니면 malformed', () => {
|
||||
for (const body of [null, [], {}, { events: 'x' }, 'text']) {
|
||||
expect(readEngineEvents(body, RECEIVED).malformed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('createdAt 을 읽지 못하면 받은 때로 · IPv4 를 담은 IPv6 는 IPv4 로 · IP 가 아니면 null', () => {
|
||||
const [event] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
createdAt: 'yesterday',
|
||||
data: { ...imapSuccess.data, remoteIp: '::ffff:211.186.95.252' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(event.occurredAt).toEqual(RECEIVED);
|
||||
expect(event.remoteIp).toBe('211.186.95.252');
|
||||
|
||||
const [noIp] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, remoteIp: 'nope' } },
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(noIp.remoteIp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineEventFingerprint (겹침 거르기 — 엔진은 다시 보낼 때 id 를 새로 만든다)', () => {
|
||||
it('엔진 이벤트 id 가 달라도 내용이 같으면 같은 지문 — 키 차례는 상관없다', () => {
|
||||
const [first] = readEngineEvents(
|
||||
{ events: [imapSuccess] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
const [retried] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
id: '17619051050099auth.success',
|
||||
data: Object.fromEntries(
|
||||
Object.entries(imapSuccess.data).reverse(),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(retried.dedupeKey).toBe(first.dedupeKey);
|
||||
expect(first.dedupeKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
|
||||
it('내용이 다르면(다른 연결의 spanId · 다른 때) 다른 지문', () => {
|
||||
const base = engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:05Z',
|
||||
imapSuccess.data,
|
||||
);
|
||||
expect(
|
||||
engineEventFingerprint('auth.success', '2026-10-31T00:00:05Z', {
|
||||
...imapSuccess.data,
|
||||
spanId: 99,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:06Z',
|
||||
imapSuccess.data,
|
||||
),
|
||||
).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('작은 판정', () => {
|
||||
it('리스너 → 방식', () => {
|
||||
expect(methodOfListener('imaps')).toBe('imap');
|
||||
expect(methodOfListener('imap')).toBe('imap');
|
||||
expect(methodOfListener('submissions')).toBe('smtp');
|
||||
expect(methodOfListener('submission')).toBe('smtp');
|
||||
expect(methodOfListener('smtp')).toBe('smtp');
|
||||
expect(methodOfListener('http')).toBeNull();
|
||||
expect(methodOfListener('pop3s')).toBeNull();
|
||||
expect(methodOfListener(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('계정 이름 → 주소 (앱 비밀번호의 로컬 이름에는 사내 도메인을 붙인다)', () => {
|
||||
expect(addressOfAccountName('doyun.lee', 'arasystem.test')).toBe(
|
||||
'doyun.lee@arasystem.test',
|
||||
);
|
||||
expect(
|
||||
addressOfAccountName(' Doyun.Lee@AraSystem.test ', 'arasystem.test'),
|
||||
).toBe('doyun.lee@arasystem.test');
|
||||
expect(addressOfAccountName('', 'arasystem.test')).toBeNull();
|
||||
});
|
||||
|
||||
it('결과 → 실패 까닭', () => {
|
||||
expect(failureReasonOf('success')).toBeNull();
|
||||
expect(failureReasonOf('failed')).toBe('wrongPassword');
|
||||
expect(failureReasonOf('mfa-required')).toBe('appPasswordRequired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { isIP, isIPv4 } from 'net';
|
||||
|
||||
import { credentialNumberToId } from './app-password';
|
||||
import type {
|
||||
LoginEventMethod,
|
||||
LoginFailureReason,
|
||||
} from './login-event.entity';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 본문 읽기 (순수 함수) — 계약 5절 "엔진 웹훅 받기".
|
||||
* 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다 (Stalwart 0.16.27 — `trc::serializers::json`).
|
||||
* 쓰는 것: type(auth.success · auth.failed · auth.mfa-required), data 의 listenerId · remoteIp · accountName · details · id.
|
||||
*
|
||||
* 겹침 거르기는 엔진 이벤트 id 가 아니라 **이벤트 내용의 지문**으로 한다 — 엔진은 id 를 보낼 때마다 새로 만든다
|
||||
* (`타임스탬프 + 프로세스 카운터 + 종류` 를 직렬화할 때 붙인다 → 실패해 다시 보낸 묶음은 같은 이벤트도 id 가 다르다, 0.16.27 소스 확인).
|
||||
* 내용(type · createdAt · data 전체 — 세션의 spanId · remotePort 가 든다)은 다시 보내도 같다.
|
||||
*/
|
||||
|
||||
/** 엔진의 앱 비밀번호 로그인 표시 (`details`) — Stalwart `auth/authentication.rs` 의 "Authenticated with app password" */
|
||||
const APP_PASSWORD_DETAILS = /\bapp password\b/i;
|
||||
/** 남기는 이벤트 종류 → 결과 */
|
||||
const OUTCOMES: ReadonlyMap<string, EngineLoginOutcome> = new Map([
|
||||
['auth.success', 'success'],
|
||||
['auth.failed', 'failed'],
|
||||
['auth.mfa-required', 'mfa-required'],
|
||||
]);
|
||||
/** IP 문자열 상한 (표의 열 길이) */
|
||||
const MAX_IP_LENGTH = 64;
|
||||
/** 계정 이름 상한 — 주소 최대 길이 (그보다 길면 사내 주소가 아니다) */
|
||||
const MAX_ACCOUNT_NAME_LENGTH = 254;
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:';
|
||||
|
||||
export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required';
|
||||
|
||||
/** 남길 만한 엔진 로그인 이벤트 하나 */
|
||||
export interface EngineLoginEvent {
|
||||
readonly dedupeKey: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly method: Exclude<LoginEventMethod, 'webmail'>;
|
||||
readonly outcome: EngineLoginOutcome;
|
||||
/** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */
|
||||
readonly accountName: string;
|
||||
readonly remoteIp: string | null;
|
||||
/** 앱 비밀번호로 들어온 성공이면 그 id — `details` 가 앱 비밀번호 표시일 때만 (IMAP 실패의 `id` 는 명령 태그다) */
|
||||
readonly appPasswordId: string | null;
|
||||
}
|
||||
|
||||
export interface EngineEventsReading {
|
||||
readonly events: EngineLoginEvent[];
|
||||
/** 버린 수 — 다른 종류 · 다른 리스너(http 등) · 모양이 틀림 */
|
||||
readonly skipped: number;
|
||||
/** 본문이 `{events: [...]}` 모양이 아니다 — 엔진 형식이 바뀌었을 수 있다 */
|
||||
readonly malformed: boolean;
|
||||
}
|
||||
|
||||
/** 결과 → 실패 까닭 (성공이면 null) */
|
||||
export function failureReasonOf(
|
||||
outcome: EngineLoginOutcome,
|
||||
): LoginFailureReason | null {
|
||||
if (outcome === 'failed') return 'wrongPassword';
|
||||
if (outcome === 'mfa-required') return 'appPasswordRequired';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 리스너 → 방식 — imap… → imap, submission… · smtp… → smtp, 그 밖(http 등 — 웹메일은 서버가 따로 남긴다)은 null */
|
||||
export function methodOfListener(
|
||||
listenerId: unknown,
|
||||
): EngineLoginEvent['method'] | null {
|
||||
if (typeof listenerId !== 'string') return null;
|
||||
const id = listenerId.toLowerCase();
|
||||
if (id.startsWith('imap')) return 'imap';
|
||||
if (id.startsWith('submission') || id.startsWith('smtp')) return 'smtp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 엔진의 계정 이름 → 주소 (로컬 이름이면 사내 도메인을 붙인다) — 소문자, 모양이 아니면 null. 직원인지는 부르는 쪽이 본다 */
|
||||
export function addressOfAccountName(
|
||||
accountName: string,
|
||||
domain: string,
|
||||
): string | null {
|
||||
const name = accountName.trim().toLowerCase();
|
||||
if (name === '' || name.length > MAX_ACCOUNT_NAME_LENGTH) return null;
|
||||
return name.includes('@') ? name : `${name}@${domain}`;
|
||||
}
|
||||
|
||||
/** 사람이 읽을 IP — IPv4 를 담은 IPv6 는 IPv4 로, IP 가 아니면 null */
|
||||
function normalizedIp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length > MAX_IP_LENGTH) return null;
|
||||
const trimmed = value.trim();
|
||||
const mapped = trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)
|
||||
? trimmed.slice(IPV4_MAPPED_PREFIX.length)
|
||||
: null;
|
||||
if (mapped !== null && isIPv4(mapped)) return mapped;
|
||||
return isIP(trimmed) === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
/** 지문용 정규화 — 객체의 키를 차례로 (값은 그대로). 깊이는 본문 모양 한도가 이미 막았다 */
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => [key, canonical(record[key])]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 이벤트 내용의 지문 — 종류 · 일어난 때(글 그대로) · data 전체 */
|
||||
export function engineEventFingerprint(
|
||||
type: string,
|
||||
createdAt: unknown,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([type, createdAt ?? null, canonical(data)]))
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 웹훅 본문 → 남길 만한 로그인 이벤트 (사내 직원인지 · 사내 목록에 있는지는 부르는 쪽이 본다) */
|
||||
export function readEngineEvents(
|
||||
body: unknown,
|
||||
receivedAt: Date,
|
||||
): EngineEventsReading {
|
||||
const list = isRecord(body) ? body.events : undefined;
|
||||
if (!Array.isArray(list)) {
|
||||
return { events: [], skipped: 0, malformed: true };
|
||||
}
|
||||
|
||||
const events: EngineLoginEvent[] = [];
|
||||
let skipped = 0;
|
||||
for (const item of list as unknown[]) {
|
||||
const event = readEvent(item, receivedAt);
|
||||
if (event) events.push(event);
|
||||
else skipped += 1;
|
||||
}
|
||||
return { events, skipped, malformed: false };
|
||||
}
|
||||
|
||||
function readEvent(item: unknown, receivedAt: Date): EngineLoginEvent | null {
|
||||
if (!isRecord(item) || typeof item.type !== 'string') return null;
|
||||
const outcome = OUTCOMES.get(item.type);
|
||||
const data = isRecord(item.data) ? item.data : null;
|
||||
if (!outcome || !data) return null;
|
||||
|
||||
const method = methodOfListener(data.listenerId);
|
||||
if (method === null) return null;
|
||||
if (typeof data.accountName !== 'string') return null;
|
||||
const accountName = data.accountName.trim().toLowerCase();
|
||||
if (accountName === '' || accountName.length > MAX_ACCOUNT_NAME_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const created =
|
||||
typeof item.createdAt === 'string' ? new Date(item.createdAt) : null;
|
||||
const occurredAt =
|
||||
created && !Number.isNaN(created.getTime()) ? created : receivedAt;
|
||||
const isAppPassword =
|
||||
outcome === 'success' &&
|
||||
typeof data.details === 'string' &&
|
||||
APP_PASSWORD_DETAILS.test(data.details);
|
||||
|
||||
return {
|
||||
dedupeKey: engineEventFingerprint(item.type, item.createdAt, data),
|
||||
occurredAt,
|
||||
method,
|
||||
outcome,
|
||||
accountName,
|
||||
remoteIp: normalizedIp(data.remoteIp),
|
||||
appPasswordId: isAppPassword ? credentialNumberToId(data.id) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { MAIL_ENGINE_EVENTS_ROUTE } from './account-security.constants';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
// 엔진만 부르는 길 (계약 5절 "엔진 웹훅 받기") — 앞단 nginx 가 /api/internal/ 을 404 로 닫고, 본문 파서가 소켓 주소(앱 네트워크) ·
|
||||
// 서명(HMAC-SHA256)을 본문을 읽기 전에 확인한다(mail-engine-events.parser.ts — 아니면 401 AUTH_001). 요청 수 한도가 없다 —
|
||||
// 엔진이 1초에 한 번까지 묶어 보낸다. 출처 검사(OriginGuard)는 엔진 요청에 Origin 이 없어 통과한다
|
||||
@ApiTags('Internal')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MailEngineEventsGuard)
|
||||
@Controller(MAIL_ENGINE_EVENTS_ROUTE)
|
||||
export class MailEngineEventsController {
|
||||
constructor(private readonly engineEvents: MailEngineEventsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'메일 엔진의 로그인 이벤트 받기 (엔진 웹훅 — 브라우저 · 앱은 부르지 않는다)',
|
||||
description:
|
||||
'본문 {"events":[{id, createdAt, type, data}]} — auth.success · auth.failed · auth.mfa-required 의 IMAP · SMTP 이벤트만 로그인 기록에 남긴다. ' +
|
||||
'사내 직원만(사내 목록에 없는 주소의 실패 · 막은 계정은 버린다), 겹침은 이벤트 내용의 지문으로 거른다. ' +
|
||||
'본문 4MB · 배열 하나 10,000 · 값 200,000 까지. DB 에 남기지 못하면 5xx — 엔진이 다시 보낸다.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Signature',
|
||||
description: '본문 원문의 HMAC-SHA256 (MAIL_WEBHOOK_SECRET), base64',
|
||||
required: true,
|
||||
})
|
||||
@ApiBody({
|
||||
description: '엔진 웹훅 본문',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
type: { type: 'string', example: 'auth.success' },
|
||||
data: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '받았다 (data: null)' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'앱 네트워크 밖의 접속 · 서명이 없거나 맞지 않음 · 서명 키가 설정되지 않음(개발) · JSON 이 아닌 본문',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '본문이 JSON 이 아니거나 모양 한도를 넘음')
|
||||
async receive(@Body() body: unknown): Promise<null> {
|
||||
await this.engineEvents.accept(body);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isVerifiedMailEngineEventsRequest } from './mail-engine-events.parser';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 처리기 앞의 가드 — 본문 파서(mail-engine-events.parser.ts)가 접속 주소 · 서명을 확인한 요청만 들인다.
|
||||
* 파서를 건너뛴 요청(JSON 이 아닌 본문 · 빈 본문 등)은 확인을 거치지 않았으므로 401 AUTH_001 — 본문을 쓰지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (isVerifiedMailEngineEventsRequest(request)) return true;
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'mail-engine-events-not-verified',
|
||||
remoteAddress: request.socket?.remoteAddress ?? 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import express from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
import request from 'supertest';
|
||||
|
||||
import { parseCidr } from '@/shared/utils/cidr';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
import {
|
||||
MailEngineEventsRejection,
|
||||
MailEngineRejectionLog,
|
||||
createMailEngineEventsBodyParser,
|
||||
hasValidEngineSignature,
|
||||
isMailEngineEventsRequest,
|
||||
isVerifiedMailEngineEventsRequest,
|
||||
} from './mail-engine-events.parser';
|
||||
|
||||
const SECRET = 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a';
|
||||
const sign = (body: string, secret = SECRET) =>
|
||||
createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest('base64');
|
||||
|
||||
describe('엔진 웹훅 서명 (X-Signature — 본문 원문의 HMAC-SHA256, base64)', () => {
|
||||
const body = '{"events":[]}';
|
||||
|
||||
it('같은 키로 서명한 본문이면 true (엔진은 키를 UTF-8 바이트로 쓴다)', () => {
|
||||
expect(hasValidEngineSignature(Buffer.from(body), sign(body), SECRET)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 키 · 바뀐 본문 · 없거나 깨진 서명 · 길이가 다른 서명은 false', () => {
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from(body),
|
||||
sign(body, `${SECRET}x`),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from('{"events":[1]}'),
|
||||
sign(body),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
for (const signature of [
|
||||
undefined,
|
||||
'',
|
||||
'not-base64!!',
|
||||
'AAAA',
|
||||
['a', 'b'],
|
||||
]) {
|
||||
expect(
|
||||
hasValidEngineSignature(Buffer.from(body), signature, SECRET),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 경로 판정', () => {
|
||||
it('POST /api/internal/mail-engine-events 만 (쿼리 · 끝 / 무관, 대소문자 변형은 넓게 — 라우터에서 404)', () => {
|
||||
for (const url of [
|
||||
'/api/internal/mail-engine-events',
|
||||
'/api/internal/mail-engine-events/',
|
||||
'/api/internal/mail-engine-events?x=1',
|
||||
'/API/Internal/Mail-Engine-Events',
|
||||
]) {
|
||||
expect(isMailEngineEventsRequest({ method: 'POST', url })).toBe(true);
|
||||
}
|
||||
expect(
|
||||
isMailEngineEventsRequest({
|
||||
method: 'GET',
|
||||
url: '/api/internal/mail-engine-events',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMailEngineEventsRequest({ method: 'POST', url: '/api/messages' }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 거절의 warn (MailEngineRejectionLog — 같은 까닭은 1분에 한 번)', () => {
|
||||
const rejection = (reason: string) =>
|
||||
new MailEngineEventsRejection(reason, '172.16.71.2');
|
||||
|
||||
it('처음 거절은 바로 남기고, 구간 안의 같은 까닭은 세기만 하다가 구간이 지나면 그 수를 붙여 다시 남긴다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
const secretMissing = rejection('mail-engine-events-secret-missing');
|
||||
|
||||
log.note(secretMissing, 'trace-1', 0);
|
||||
for (let second = 1; second < 60; second += 1) {
|
||||
log.note(secretMissing, `trace-${second}`, second * 1000);
|
||||
}
|
||||
expect(lines).toEqual([
|
||||
'[AUTH_001][trace-1] POST /api/internal/mail-engine-events {"reason":"mail-engine-events-secret-missing","remoteAddress":"172.16.71.2"} (같은 까닭은 60초에 한 번만 남긴다)',
|
||||
]);
|
||||
|
||||
log.note(secretMissing, 'trace-60', 60_000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('[trace-60]');
|
||||
expect(lines[1]).toContain('앞 구간에 같은 까닭으로 59번 더 거절했다');
|
||||
});
|
||||
|
||||
it('까닭이 다르면 따로 센다 — 서명 틀림은 서명 키 없음에 묻히지 않는다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
log.note(rejection('mail-engine-events-secret-missing'), undefined, 0);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 1000);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 2000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('mail-engine-events-bad-signature');
|
||||
expect(lines[1]).not.toContain('[undefined]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => {
|
||||
const PATH = '/api/internal/mail-engine-events';
|
||||
const settings = (
|
||||
overrides: Partial<AccountSecuritySettings> = {},
|
||||
): AccountSecuritySettings => ({
|
||||
webhookSecret: SECRET,
|
||||
appNetwork: parseCidr('127.0.0.0/8')!,
|
||||
isAppNetworkAddress: () => true,
|
||||
...overrides,
|
||||
});
|
||||
const appWith = (config: AccountSecuritySettings, lines: string[]) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.headers['x-request-id'] = 'trace-e';
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
createMailEngineEventsBodyParser(
|
||||
config,
|
||||
new MailEngineRejectionLog((line) => lines.push(line)),
|
||||
),
|
||||
);
|
||||
app.post(PATH, (req, res) => {
|
||||
res.json({
|
||||
verified: isVerifiedMailEngineEventsRequest(req),
|
||||
body: req.body as unknown,
|
||||
});
|
||||
});
|
||||
return app;
|
||||
};
|
||||
const AUTH_001_BODY = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
traceId: 'trace-e',
|
||||
},
|
||||
};
|
||||
|
||||
it('서명이 맞으면 본문을 읽고 표시를 남긴다 — 빈 묶음도 (setup 의 확인)', async () => {
|
||||
const lines: string[] = [];
|
||||
const raw = '{"events":[]}';
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Signature', sign(raw))
|
||||
.set('X-Arasystem-Setup', '[setup.mjs] key=abc')
|
||||
.send(raw)
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ verified: true, body: { events: [] } });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('서명 키가 없거나 · 서명이 틀리거나 · 앱 네트워크 밖이면 표준 실패 형식의 401 AUTH_001 — warn 은 까닭마다 한 번', async () => {
|
||||
const raw = '{"events":[]}';
|
||||
const cases: [AccountSecuritySettings, string | undefined, string][] = [
|
||||
[settings({ webhookSecret: null }), sign(raw), 'secret-missing'],
|
||||
[settings(), sign(raw, `${SECRET}x`), 'bad-signature'],
|
||||
[settings(), undefined, 'bad-signature'],
|
||||
[
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
sign(raw),
|
||||
'outside-app-network',
|
||||
],
|
||||
];
|
||||
for (const [config, signature, reason] of cases) {
|
||||
const lines: string[] = [];
|
||||
const app = appWith(config, lines);
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const call = request(app)
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json');
|
||||
if (signature !== undefined) call.set('X-Signature', signature);
|
||||
const res = await call.send(raw).expect(401);
|
||||
expect(res.body).toEqual(AUTH_001_BODY);
|
||||
}
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
expect(lines[0]).not.toContain(SECRET);
|
||||
}
|
||||
});
|
||||
|
||||
describe('본문을 받기 전에 — 접속 주소 · 서명 키 (2026-10-31 리뷰: 앱 네트워크 밖도 4MB 까지 받았다)', () => {
|
||||
/** 머리글만 보내고(본문은 보내지 않는다) 응답을 기다린다 — 본문을 기다리는 파서라면 답하지 않는다 */
|
||||
async function headersOnly(
|
||||
config: AccountSecuritySettings,
|
||||
lines: string[],
|
||||
): Promise<string> {
|
||||
const server: Server = createServer(appWith(config, lines));
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
let text = '';
|
||||
// 답하지 않는 것을 보는 기한 — 일찍 답하는 경로는 곧바로 끝나므로 이 기한에 기대지 않는다
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve('NO-ANSWER');
|
||||
}, 1_000);
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
text += chunk.toString('utf8');
|
||||
});
|
||||
socket.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(text);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
socket.write(
|
||||
`POST ${PATH} HTTP/1.1\r\nHost: backend\r\nContent-Type: application/json\r\n` +
|
||||
`X-Signature: ${sign('{"events":[]}')}\r\nContent-Length: 3000000\r\n\r\n`,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'앱 네트워크 밖',
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
'outside-app-network',
|
||||
],
|
||||
[
|
||||
'서명 키 없음(개발)',
|
||||
settings({ webhookSecret: null }),
|
||||
'secret-missing',
|
||||
],
|
||||
])(
|
||||
'%s — 본문을 기다리지 않고 바로 401 AUTH_001 을 쓰고 연결을 닫는다',
|
||||
async (_label, config, reason) => {
|
||||
const lines: string[] = [];
|
||||
const answer = await headersOnly(config, lines);
|
||||
|
||||
expect(answer).toMatch(/^HTTP\/1\.1 401 /);
|
||||
expect(answer.toLowerCase()).toContain('connection: close');
|
||||
expect(answer).toContain('"code":"AUTH_001"');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('주소 · 서명 키가 맞으면 본문을 받는다 — 서명은 원문을 다 받은 뒤에 본다(본문이 오지 않으면 답하지 않는다)', async () => {
|
||||
const lines: string[] = [];
|
||||
await expect(headersOnly(settings(), lines)).resolves.toBe('NO-ANSWER');
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON 이 아닌 본문은 읽지 않고 지나간다 — 처리기 앞의 가드가 표시 없는 요청을 막는다', async () => {
|
||||
const lines: string[] = [];
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'text/plain')
|
||||
.send('{"events":[]}')
|
||||
.expect(200);
|
||||
expect(res.body).toMatchObject({ verified: false });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { HttpStatus, Logger } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { json, type RequestHandler } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { findJsonTextShapeProblem } from '@/shared/utils/json-shape';
|
||||
import {
|
||||
MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
MAIL_ENGINE_EVENTS_ROUTE,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
MAIL_ENGINE_SIGNATURE_HEADER,
|
||||
} from './account-security.constants';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 본문 파서 (계약 5절 "엔진 웹훅 받기") — 서버 몫의 두 겹:
|
||||
* 1. 접속 주소 — `X-Forwarded-For` 가 아니라 소켓 주소가 앱 네트워크(`APP_NETWORK_SUBNET`) 안이어야 한다.
|
||||
* 서명 키가 없는지(개발)와 함께 **본문을 받기 전에** 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다
|
||||
* (앱 네트워크 밖의 접속이 4MB 까지 보내게 두지 않는다 — 2026-10-31 리뷰)
|
||||
* 2. 서명 — 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞아야 한다 (상수 시간 비교).
|
||||
* 원문이 있어야 하므로 본문을 받은 뒤 · JSON 으로 읽기 전에(body-parser 의 verify) 본다
|
||||
* 하나라도 아니면 401 AUTH_001(표준 실패 형식)을 이 파서가 바로 쓴다. warn 에는 까닭과 접속 주소만 남기고,
|
||||
* 같은 까닭은 1분에 한 번만 남긴다(MailEngineRejectionLog — 서명 키가 어긋나면 엔진이 1초마다 보낸다).
|
||||
* (세 번째 겹 — 앞단 nginx 가 /api/internal/ 을 404 로 닫는 것은 release-ops 몫이다)
|
||||
*
|
||||
* 이 경로만 JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000 — 공격이 오면 엔진의 1초 묶음이 커진다).
|
||||
* 통과한 요청에는 표시를 남긴다 — 처리기 앞의 가드(MailEngineEventsGuard)가 표시 없는 요청(JSON 이 아니라 이 파서를 건너뛴 본문 등)을 막는다.
|
||||
* 서명 키 · 본문은 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
|
||||
/** 이 파서를 지난(주소 · 서명이 맞은) 요청의 표시 */
|
||||
const VERIFIED = Symbol('mail-engine-events-verified');
|
||||
|
||||
type MarkedRequest = IncomingMessage & { [VERIFIED]?: true };
|
||||
|
||||
const MAIL_ENGINE_EVENTS_PATH = `/api/${MAIL_ENGINE_EVENTS_ROUTE}`;
|
||||
/** UTF-8 의 비교용 이름 — app.setup.ts 의 verifyJsonBody 와 같은 정규화 (body-parser 가 UTF-8 로 읽는 이름들) */
|
||||
const UTF8_CHARSET = 'utf8';
|
||||
/** SHA-256 의 길이(바이트) */
|
||||
const SHA256_BYTES = 32;
|
||||
|
||||
/** 엔진 웹훅 요청인가 — POST /api/internal/mail-engine-events (쿼리 · 끝 `/` 는 가리지 않고, 대소문자는 넓게 — 변형은 라우터에서 404) */
|
||||
export function isMailEngineEventsRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === MAIL_ENGINE_EVENTS_PATH;
|
||||
}
|
||||
|
||||
/** 파서가 주소 · 서명을 확인한 요청인가 (가드가 본다) */
|
||||
export function isVerifiedMailEngineEventsRequest(req: object): boolean {
|
||||
return (req as MarkedRequest)[VERIFIED] === true;
|
||||
}
|
||||
|
||||
/** 주소 · 서명 확인에서 거절한 요청 — 파서가 401 AUTH_001 을 바로 쓴다 (전역 필터로 보내지 않는다) */
|
||||
export class MailEngineEventsRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: string,
|
||||
readonly remoteAddress: string,
|
||||
) {
|
||||
super(reason);
|
||||
this.name = 'MailEngineEventsRejection';
|
||||
}
|
||||
}
|
||||
|
||||
function rejected(
|
||||
reason: string,
|
||||
req: IncomingMessage,
|
||||
): MailEngineEventsRejection {
|
||||
return new MailEngineEventsRejection(
|
||||
reason,
|
||||
req.socket?.remoteAddress ?? 'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹훅 거절의 warn — 같은 까닭은 구간(1분)에 한 번만 남기고, 그사이 거절한 수는 다음 줄에 붙인다.
|
||||
* 까닭은 셋뿐이라(앱 네트워크 밖 · 서명 키 없음 · 서명 틀림) 기억이 커지지 않는다. 서명 · 본문은 받지 않는다
|
||||
*/
|
||||
export class MailEngineRejectionLog {
|
||||
private readonly windows = new Map<
|
||||
string,
|
||||
{ endsAt: number; suppressed: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly write: (message: string) => void,
|
||||
private readonly windowMs: number = MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
) {}
|
||||
|
||||
note(
|
||||
rejection: MailEngineEventsRejection,
|
||||
traceId: string | undefined,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
const current = this.windows.get(rejection.reason);
|
||||
if (current && now < current.endsAt) {
|
||||
current.suppressed += 1;
|
||||
return;
|
||||
}
|
||||
this.windows.set(rejection.reason, {
|
||||
endsAt: now + this.windowMs,
|
||||
suppressed: 0,
|
||||
});
|
||||
const context = JSON.stringify({
|
||||
reason: rejection.reason,
|
||||
remoteAddress: rejection.remoteAddress,
|
||||
});
|
||||
const earlier =
|
||||
current && current.suppressed > 0
|
||||
? ` — 앞 구간에 같은 까닭으로 ${current.suppressed}번 더 거절했다`
|
||||
: '';
|
||||
this.write(
|
||||
`[AUTH_001]${traceId ? `[${traceId}]` : ''} POST ${MAIL_ENGINE_EVENTS_PATH} ${context}${earlier} (같은 까닭은 ${Math.round(this.windowMs / 1000)}초에 한 번만 남긴다)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `X-Signature`(base64) 가 본문의 HMAC-SHA256 과 같은가 — 길이가 다르면 바로 false, 같으면 상수 시간 비교 */
|
||||
export function hasValidEngineSignature(
|
||||
body: Buffer,
|
||||
signature: string | string[] | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (typeof signature !== 'string' || signature === '') return false;
|
||||
const presented = Buffer.from(signature.trim(), 'base64');
|
||||
if (presented.length !== SHA256_BYTES) return false;
|
||||
const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest();
|
||||
return timingSafeEqual(expected, presented);
|
||||
}
|
||||
|
||||
/** 본문을 읽기 전의 확인 — body-parser 의 verify (던지면 그 상태 코드로 끝난다 — BusinessException 의 status) */
|
||||
function verifier(settings: AccountSecuritySettings) {
|
||||
return (
|
||||
req: IncomingMessage,
|
||||
_res: ServerResponse,
|
||||
buffer: Buffer,
|
||||
encoding: string,
|
||||
): void => {
|
||||
// 접속 주소 · 서명 키 유무는 본문을 받기 전에 이미 봤다(createMailEngineEventsBodyParser) — 키는 타입을 좁히려고 한 번 더
|
||||
if (settings.webhookSecret === null) {
|
||||
throw rejected('mail-engine-events-secret-missing', req);
|
||||
}
|
||||
if (
|
||||
!hasValidEngineSignature(
|
||||
buffer,
|
||||
req.headers[MAIL_ENGINE_SIGNATURE_HEADER],
|
||||
settings.webhookSecret,
|
||||
)
|
||||
) {
|
||||
throw rejected('mail-engine-events-bad-signature', req);
|
||||
}
|
||||
if (encoding.toLowerCase().replace(/[^0-9a-z]/g, '') !== UTF8_CHARSET) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-not-utf8' },
|
||||
});
|
||||
}
|
||||
const problem = findJsonTextShapeProblem(
|
||||
buffer,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
);
|
||||
if (problem !== null) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-shape-limit', problem, stage: 'before-parse' },
|
||||
});
|
||||
}
|
||||
(req as MarkedRequest)[VERIFIED] = true;
|
||||
};
|
||||
}
|
||||
|
||||
const logger = new Logger('MailEngineEvents');
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 JSON 파서 — configureApp 이 **다른 JSON 파서보다 먼저** 붙인다(이 경로의 본문을 1MB 파서가 413 으로 끊지 않게).
|
||||
* 이 경로의 JSON 본문만 읽는다 — 그 밖의 요청은 건너뛴다.
|
||||
* - 이 경로면 본문을 받기 전에 접속 주소(앱 네트워크) · 서명 키 유무를 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다(`Connection: close`)
|
||||
* - 서명은 원문을 받은 뒤 JSON 으로 읽기 전에 본다
|
||||
* 거절은 여기서 표준 실패 형식의 401 AUTH_001 을 쓴다(전역 필터와 같은 모양 — 요청마다 warn 을 남기지 않으려고).
|
||||
* 서명이 맞은 뒤의 실패(UTF-8 아님 · 모양 한도 — 엔진이 보낸 본문)는 전역 필터로 간다
|
||||
*/
|
||||
export function createMailEngineEventsBodyParser(
|
||||
settings: AccountSecuritySettings,
|
||||
rejections: MailEngineRejectionLog = new MailEngineRejectionLog((message) =>
|
||||
logger.warn(message),
|
||||
),
|
||||
): RequestHandler {
|
||||
const parse = json({
|
||||
limit: MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
isMailEngineEventsRequest(req) &&
|
||||
(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() ===
|
||||
'application/json',
|
||||
verify: verifier(settings),
|
||||
});
|
||||
return (req, res, next) => {
|
||||
const reject = (
|
||||
rejection: MailEngineEventsRejection,
|
||||
bodyUnread: boolean,
|
||||
) => {
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
rejections.note(rejection, traceId);
|
||||
// 본문을 받지 않고 끝낸 응답 — 연결을 닫아 남은 본문도 받지 않는다
|
||||
if (bodyUnread) res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: getErrorDefinition('AUTH_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isMailEngineEventsRequest(req)) {
|
||||
if (!settings.isAppNetworkAddress(req.socket?.remoteAddress)) {
|
||||
reject(rejected('mail-engine-events-outside-app-network', req), true);
|
||||
return;
|
||||
}
|
||||
if (settings.webhookSecret === null) {
|
||||
reject(rejected('mail-engine-events-secret-missing', req), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
parse(req, res, (error?: unknown) => {
|
||||
if (error instanceof MailEngineEventsRejection) {
|
||||
reject(error, false);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import type { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set([
|
||||
'admin@arasystem.test',
|
||||
'mail-directory@arasystem.test',
|
||||
]),
|
||||
directoryAccount: 'mail-directory@arasystem.test',
|
||||
};
|
||||
|
||||
const event = (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
createdAt = '2026-10-31T00:00:05Z',
|
||||
) => ({
|
||||
id: `${Math.random()}`,
|
||||
createdAt,
|
||||
type,
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
spanId: 1,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
describe('MailEngineEventsService (엔진 웹훅 — 메일 프로그램의 로그인 기록)', () => {
|
||||
let inserted: Omit<LoginEventEntity, 'id'>[];
|
||||
let existingKeys: Set<string>;
|
||||
let loginEvents: {
|
||||
insertEngineEvents: jest.Mock;
|
||||
isKnownEmployee: jest.Mock;
|
||||
};
|
||||
let appPasswords: { recordUse: jest.Mock };
|
||||
let service: MailEngineEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
inserted = [];
|
||||
existingKeys = new Set();
|
||||
loginEvents = {
|
||||
insertEngineEvents: jest.fn((rows: Omit<LoginEventEntity, 'id'>[]) => {
|
||||
const fresh = rows.filter(
|
||||
(row) => !existingKeys.has(row.dedupeKey ?? ''),
|
||||
);
|
||||
inserted.push(...fresh);
|
||||
for (const row of fresh) existingKeys.add(row.dedupeKey ?? '');
|
||||
return Promise.resolve(
|
||||
new Set(fresh.map((row) => row.dedupeKey ?? '')),
|
||||
);
|
||||
}),
|
||||
isKnownEmployee: jest.fn((address: string) =>
|
||||
Promise.resolve(address !== 'nobody@arasystem.test'),
|
||||
),
|
||||
};
|
||||
appPasswords = { recordUse: jest.fn().mockResolvedValue('회사 PC 아웃룩') };
|
||||
service = new MailEngineEventsService(
|
||||
settings,
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 묶음은 아무것도 남기지 않고 끝난다 (setup 의 서명한 빈 묶음 — 200)', async () => {
|
||||
await expect(service.accept({ events: [] })).resolves.toEqual({
|
||||
kept: 0,
|
||||
duplicates: 0,
|
||||
dropped: 0,
|
||||
});
|
||||
expect(inserted).toEqual([]);
|
||||
});
|
||||
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 남긴다 — 실패 까닭 · IP · 방식', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
listenerId: 'submissions',
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
spanId: 2,
|
||||
}),
|
||||
event('auth.mfa-required', {
|
||||
accountName: 'ara.kim@arasystem.test',
|
||||
spanId: 3,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 3, duplicates: 0, dropped: 0 });
|
||||
expect(
|
||||
inserted.map(({ userEmail, method, isSuccess, failureReason }) => ({
|
||||
userEmail,
|
||||
method,
|
||||
isSuccess,
|
||||
failureReason,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: true,
|
||||
failureReason: null,
|
||||
},
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'smtp',
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
},
|
||||
{
|
||||
userEmail: 'ara.kim@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: false,
|
||||
failureReason: 'appPasswordRequired',
|
||||
},
|
||||
]);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
ip: '211.186.95.252',
|
||||
isTwoFactor: false,
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('사내 직원만 — 다른 도메인 · 막은 계정(관리자 · 사내 목록 계정 · 게이트웨이)은 버린다, 사내 목록에 없는 주소의 실패도 버린다', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.failed', { accountName: 'someone@gmail.com' }),
|
||||
event('auth.success', { accountName: 'admin@arasystem.test' }),
|
||||
event('auth.success', { accountName: 'mail-gateway@arasystem.test' }),
|
||||
event('auth.failed', { accountName: 'nobody@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
accountName: 'nobody@arasystem.test',
|
||||
spanId: 9,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 0, duplicates: 0, dropped: 5 });
|
||||
// 같은 묶음의 같은 주소는 사내 목록을 한 번만 묻는다
|
||||
expect(loginEvents.isKnownEmployee).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다', async () => {
|
||||
await service.accept({
|
||||
events: [
|
||||
event('auth.success', {
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(appPasswords.recordUse).toHaveBeenCalledWith(
|
||||
'doyun.lee@arasystem.test',
|
||||
'b',
|
||||
{
|
||||
at: new Date('2026-10-31T00:00:05Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
},
|
||||
);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
appPasswordId: 'b',
|
||||
appPasswordName: '회사 PC 아웃룩',
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다 · 한 묶음 안의 같은 이벤트도', async () => {
|
||||
const original = event('auth.success', {
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
});
|
||||
await service.accept({ events: [original, { ...original, id: 'again' }] });
|
||||
const retried = await service.accept({
|
||||
events: [{ ...original, id: 'retried-with-new-id' }],
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(retried).toEqual({ kept: 0, duplicates: 1, dropped: 0 });
|
||||
});
|
||||
|
||||
it('본문 모양이 다르면 아무것도 남기지 않고 warn 을 한 번만 남긴다 (본문은 남기지 않는다)', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await service.accept({ nope: true });
|
||||
await service.accept('text');
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).not.toContain('nope');
|
||||
expect(loginEvents.insertEngineEvents).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('DB 에 남기지 못하면 던진다 — 5xx 로 답해 엔진이 다시 보낸다', async () => {
|
||||
loginEvents.insertEngineEvents.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isEmployeeAddress } from '@/modules/auth/employee-address';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import {
|
||||
addressOfAccountName,
|
||||
failureReasonOf,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
/** 웹훅 한 번의 처리 결과 (debug 로그 · 테스트) */
|
||||
export interface EngineEventsAccepted {
|
||||
/** 새로 남긴 수 */
|
||||
readonly kept: number;
|
||||
/** 이미 남긴 것(엔진이 다시 보낸 묶음) */
|
||||
readonly duplicates: number;
|
||||
/** 버린 수 — 다른 종류 · 리스너 · 사내 직원이 아님 · 사내 목록에 없는 주소의 실패 · 막은 계정 */
|
||||
readonly dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 받기 (계약 5절 "엔진 웹훅 받기") — 메일 프로그램(IMAP · SMTP)의 로그인을 로그인 기록에 남긴다.
|
||||
* 접속 주소 · 서명은 본문 파서가 이미 확인했다 (mail-engine-events.parser.ts).
|
||||
*
|
||||
* - 사내 도메인의 직원만 — 막은 계정(관리자 · 게이트웨이 · 사내 목록 계정)과 사내 목록에 없는 주소의 실패는 버린다(엔진은 서버 전체의 이벤트를 보낸다)
|
||||
* - 겹침은 이벤트 내용의 지문으로 거른다(엔진이 다시 보낸 묶음 — 엔진의 이벤트 id 는 보낼 때마다 바뀐다)
|
||||
* - 앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다
|
||||
* - 처리는 기다린 뒤 답한다(DB 한 번 — 수 ms). DB 가 실패하면 5xx 로 답해 엔진이 다시 보낸다(5분까지 — 지문이 같아 겹치지 않는다)
|
||||
* 웹훅 수신은 로그에 남기지 않는다(반복되는 정상 흐름) — 남긴 수 · 버린 수는 debug
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsService {
|
||||
private readonly logger = new Logger(MailEngineEventsService.name);
|
||||
/** 본문 모양이 다르다는 warn 을 이미 남겼는가 (엔진 판이 바뀐 경우 — 한 번만) */
|
||||
private malformedReported = false;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly loginEvents: LoginEventsService,
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
) {}
|
||||
|
||||
async accept(
|
||||
body: unknown,
|
||||
receivedAt: Date = new Date(),
|
||||
): Promise<EngineEventsAccepted> {
|
||||
const reading = readEngineEvents(body, receivedAt);
|
||||
if (reading.malformed) {
|
||||
if (!this.malformedReported) {
|
||||
this.malformedReported = true;
|
||||
this.logger.warn(
|
||||
'[MAIL_001] 엔진 웹훅 본문이 {"events":[…]} 모양이 아닙니다 — 메일 엔진 판이 바뀌었는지 확인하세요. (본문은 남기지 않는다)',
|
||||
);
|
||||
}
|
||||
return { kept: 0, duplicates: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
if (!domain) {
|
||||
return { kept: 0, duplicates: 0, dropped: reading.events.length };
|
||||
}
|
||||
|
||||
const rows = new Map<string, Omit<LoginEventEntity, 'id'>>();
|
||||
const known = new Map<string, boolean>();
|
||||
let dropped = reading.skipped;
|
||||
for (const event of reading.events) {
|
||||
const address = addressOfAccountName(event.accountName, domain);
|
||||
if (
|
||||
address === null ||
|
||||
!isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) ||
|
||||
rows.has(event.dedupeKey)
|
||||
) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
if (event.outcome !== 'success') {
|
||||
let isKnown = known.get(address);
|
||||
if (isKnown === undefined) {
|
||||
isKnown = await this.loginEvents.isKnownEmployee(address);
|
||||
known.set(address, isKnown);
|
||||
}
|
||||
if (!isKnown) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const appPasswordName = event.appPasswordId
|
||||
? await this.appPasswords.recordUse(address, event.appPasswordId, {
|
||||
at: event.occurredAt,
|
||||
method: event.method,
|
||||
ip: event.remoteIp,
|
||||
})
|
||||
: null;
|
||||
rows.set(event.dedupeKey, {
|
||||
userEmail: address,
|
||||
occurredAt: event.occurredAt,
|
||||
method: event.method,
|
||||
isSuccess: event.outcome === 'success',
|
||||
isTwoFactor: false,
|
||||
failureReason: failureReasonOf(event.outcome),
|
||||
ip: event.remoteIp,
|
||||
userAgent: null,
|
||||
appPasswordId: event.appPasswordId,
|
||||
appPasswordName,
|
||||
dedupeKey: event.dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
const inserted = await this.loginEvents.insertEngineEvents([
|
||||
...rows.values(),
|
||||
]);
|
||||
const result = {
|
||||
kept: inserted.size,
|
||||
duplicates: rows.size - inserted.size,
|
||||
dropped,
|
||||
};
|
||||
if (result.kept + result.duplicates + result.dropped > 0) {
|
||||
this.logger.debug(
|
||||
`엔진 로그인 이벤트 — 남김 ${result.kept} · 이미 있음 ${result.duplicates} · 버림 ${result.dropped}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { TWO_FACTOR_CODE_ADDRESS_LIMIT } from '@/modules/auth/auth.constants';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const IP = '203.0.113.10';
|
||||
const accepted = () =>
|
||||
Promise.resolve({ kind: 'accepted' as const, value: 'ok' });
|
||||
const rejected = () => Promise.resolve({ kind: 'rejected' as const });
|
||||
|
||||
/** 손으로 푸는 약속 — 확인(run)이 걸려 있는 동안 다른 요청을 보낸다 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('TwoFactorCodeGuard (코드 한도 · 다시 쓰기 막기 — 계약 5절)', () => {
|
||||
let now: number;
|
||||
let guard: TwoFactorCodeGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_800_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
guard = new TwoFactorCodeGuard(new LoginAttemptService());
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('받으면 값을 돌려준다 · 받지 않으면 400 AUTH_006 (context 에 코드는 없다)', async () => {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await guard.check({ email: ARA, ip: IP }, '654321', rejected);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('AUTH_006');
|
||||
expect((failure as BusinessException).getStatus()).toBe(400);
|
||||
expect(
|
||||
JSON.stringify((failure as BusinessException).context),
|
||||
).not.toContain('654321');
|
||||
});
|
||||
|
||||
it(`성공에 쓴 코드는 ${TWO_FACTOR_CODE_REUSE_MS / 60_000}분 동안 다시 받지 않는다 — 확인(run) 없이 AUTH_006 · 실패로 센다, 다른 직원 · 다른 코드는 그대로`, async () => {
|
||||
guard.remember(ARA, '123456');
|
||||
const run = jest.fn(accepted);
|
||||
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', run)),
|
||||
).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
guard.check({ email: 'doyun.lee@arasystem.test', ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '111111', run),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
now += TWO_FACTOR_CODE_REUSE_MS;
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it(`실패가 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}번 쌓이면 맞는 코드도 REQ_001 — 다시 쓴 코드도 실패로 센다`, async () => {
|
||||
guard.remember(ARA, '999999');
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '999999', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
for (let i = 1; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
'000000',
|
||||
rejected,
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('확인이 던지면(엔진 장애) 자리를 돌려주고 그대로 던진다 — 실패로 세지 않는다', async () => {
|
||||
const down = () => Promise.reject(new BusinessException('MAIL_001'));
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', down)),
|
||||
).toBe('MAIL_001');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
describe('같은 코드의 동시 요청 — 확인을 시작할 때 "확인 중"을 차지한다 (2026-10-31 리뷰)', () => {
|
||||
it('앞 요청이 확인하는 동안 같은 코드는 확인(run) 없이 400 AUTH_006 · 실패로 센다 — 앞이 받으면 그 코드는 쓴 코드다', async () => {
|
||||
const gate = deferred<{ kind: 'accepted'; value: string }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => gate.promise,
|
||||
);
|
||||
expect(guard.isPending(ARA, '123456')).toBe(true);
|
||||
|
||||
const run = jest.fn(accepted);
|
||||
const second = await codeOf(
|
||||
guard.check({ email: ARA, ip: '198.51.100.7' }, '123456', run),
|
||||
);
|
||||
expect(second).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
// 다른 코드 · 다른 직원은 그대로 확인한다
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '654321', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
gate.resolve({ kind: 'accepted', value: 'first' });
|
||||
await expect(first).resolves.toBe('first');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('받으면 일이 끝나기 전에(돌려주는 그 자리에서) 기억한다 — 뒤의 일(세션 저장 등)이 걸려 있어도 같은 코드는 막힌다', async () => {
|
||||
await guard.check({ email: ARA, ip: IP }, '123456', accepted);
|
||||
// 부른 쪽이 아직 세션을 만드는 중이라고 해도 같은 코드는 이미 쓴 코드다
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
});
|
||||
|
||||
it('앞 요청이 받지 않았거나 던졌으면 차지를 풀어 같은 코드로 다시 확인할 수 있다', async () => {
|
||||
const rejectedGate = deferred<{ kind: 'rejected' }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => rejectedGate.promise,
|
||||
);
|
||||
rejectedGate.resolve({ kind: 'rejected' });
|
||||
expect(await codeOf(first)).toBe('AUTH_006');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
|
||||
const down = deferred<never>();
|
||||
const second = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => down.promise,
|
||||
);
|
||||
down.reject(new BusinessException('MAIL_001'));
|
||||
expect(await codeOf(second)).toBe('MAIL_001');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('쓰지 않고 끝남(unused) — 값을 돌려주고 세지도 기억하지도 않는다 (예: 엔진이 새 비밀번호를 거절)', async () => {
|
||||
const unused = () =>
|
||||
Promise.resolve({ kind: 'unused' as const, value: 'nothing-changed' });
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', unused),
|
||||
).resolves.toBe('nothing-changed');
|
||||
}
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('코드가 글이 아니어도(검사를 비켜 온 null) 차지한 실패 자리를 끝낸다 — 여러 번 와도 맞는 코드가 막히지 않는다', async () => {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
null as unknown as string,
|
||||
accepted,
|
||||
),
|
||||
),
|
||||
).toBe('NOT_BUSINESS');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('정리 작업이 지난 기억을 지운다', () => {
|
||||
guard.remember(ARA, '123456');
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
guard.sweep(now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
expect(guard.isUsed(ARA, '123456', now + TWO_FACTOR_CODE_REUSE_MS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorLocks (같은 직원의 2단계 인증 바꾸기는 한 번에 하나)', () => {
|
||||
it('잡혀 있으면 기다리지 않고 REQ_001 — 끝나면 풀린다, 다른 직원은 따로', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
let release!: () => void;
|
||||
const first = locks.run(
|
||||
ARA,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await codeOf(locks.run(ARA, () => Promise.resolve()))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
await expect(
|
||||
locks.run('doyun.lee@arasystem.test', () => Promise.resolve('other')),
|
||||
).resolves.toBe('other');
|
||||
|
||||
release();
|
||||
await first;
|
||||
await expect(locks.run(ARA, () => Promise.resolve('next'))).resolves.toBe(
|
||||
'next',
|
||||
);
|
||||
});
|
||||
|
||||
it('일이 던져도 풀린다 · 푸는 함수는 몇 번 불러도 한 번만', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
expect(
|
||||
await codeOf(
|
||||
locks.run(ARA, () => Promise.reject(new BusinessException('MAIL_001'))),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
const release = locks.acquire(ARA);
|
||||
release();
|
||||
release();
|
||||
expect(() => locks.acquire(ARA)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorEnrollments (기다리는 등록 — 직원마다 하나 · 10분)', () => {
|
||||
const secret = () => Buffer.alloc(20, 7);
|
||||
|
||||
it('새 등록이 앞의 것을 바꾼다 — 앞의 id 로는 찾지 못하고 그 비밀 바이트는 지운다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
const first = { id: 'first', secret: secret(), expiresAt: 2_000 };
|
||||
enrollments.put(ARA, first);
|
||||
enrollments.put(ARA, { id: 'second', secret: secret(), expiresAt: 2_000 });
|
||||
|
||||
expect(enrollments.find(ARA, 'first', 1_000)).toBeNull();
|
||||
expect(enrollments.find(ARA, 'second', 1_000)?.id).toBe('second');
|
||||
expect(first.secret.every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('시간이 지나면 찾지 못하고 버린다 · 정리 작업도 버린다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
expect(enrollments.find(ARA, 'e', 2_000)).toBeNull();
|
||||
expect(enrollments.size).toBe(0);
|
||||
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
enrollments.sweep(2_000);
|
||||
expect(enrollments.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 기억을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 코드 확인 한 번(`run`)의 결과
|
||||
* - `accepted`: 코드를 받았다 — 쓴 코드로 기억한다(2분). 뒤의 일(세션 · 기록 저장)이 실패해도 그 코드는 쓴 것으로 둔다
|
||||
* - `rejected`: 코드 틀림 — 실패로 센다(400 AUTH_006)
|
||||
* - `unused`: 코드와 상관없이 아무것도 바뀌지 않고 끝났다(엔진이 새 비밀번호를 거절하는 등) — 세지도 기억하지도 않는다
|
||||
*/
|
||||
export type CodeCheck<T> =
|
||||
| { readonly kind: 'accepted'; readonly value: T }
|
||||
| { readonly kind: 'rejected' }
|
||||
| { readonly kind: 'unused'; readonly value: T };
|
||||
|
||||
/** 코드를 확인하는 사람 — 정규화한 주소 · 클라이언트 IP */
|
||||
export interface CodeSource {
|
||||
readonly email: string;
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드의 한도와 다시 쓰기 막기 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 쓴다.
|
||||
*
|
||||
* - 코드 실패: 같은 주소 15분 5회 · 24시간 20회, 같은 IP 는 로그인 실패 한도에 함께 센다 (LoginAttemptService.beginCode) — 넘으면 REQ_001.
|
||||
* 확인하기 **전에** 실패 한 번을 차지하고, 맞으면 돌려준다 (로그인 실패 한도와 같은 결 — 동시 요청이 한도를 넘지 못하게)
|
||||
* - 다시 쓴 코드: 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다 → 400 AUTH_006 (코드 실패로 센다).
|
||||
* 엔진은 같은 코드를 다시 받으므로 서버가 막는다. 확인을 시작할 때 (주소, 코드)를 "확인 중"으로 차지하고(확인과 차지 사이에 await 가 없다)
|
||||
* 같은 코드가 겹치면 AUTH_006 이다 — 동시에 보낸 두 요청이 둘 다 지나가지 못하게 (2026-10-31 리뷰: 로그인 두 번이 모두 signedIn 이었다).
|
||||
* 받으면 그 자리에서 기억으로 바꾸고(뒤의 일이 실패해도), 받지 않았거나 던졌거나 쓰지 않고 끝났으면 차지를 푼다
|
||||
* - 코드 · 기억의 키에 원문을 두지 않는다 (주소 + 코드의 SHA-256). 코드는 로그 · 오류 context 어디에도 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorCodeGuard {
|
||||
/** 키(주소 + 코드의 해시) → 다시 받는 때(epoch ms) */
|
||||
private readonly usedCodes = new Map<string, number>();
|
||||
/** 지금 확인 중인 키 — 같은 코드의 동시 요청을 막는다 (확인이 끝나면 기억으로 바뀌거나 지워진다) */
|
||||
private readonly pendingCodes = new Set<string>();
|
||||
|
||||
constructor(private readonly attempts: LoginAttemptService) {}
|
||||
|
||||
/**
|
||||
* 코드 한 번을 확인한다 — 한도(REQ_001) → 다시 쓴 코드 · 확인 중인 같은 코드(AUTH_006) → `run`(엔진 · 등록의 비밀로 실제 확인).
|
||||
* 여기까지(실패 자리 차지 · 다시 쓴 코드 확인 · 확인 중 차지)는 await 없이 한 번에 일어난다.
|
||||
* - `accepted`: 차지한 자리를 돌려주고 코드를 기억한 뒤 값을 돌려준다
|
||||
* - `rejected`: 실패로 남기고 400 AUTH_006
|
||||
* - `unused`: 자리를 돌려주고(세지 않음) 기억하지 않고 값을 돌려준다
|
||||
* - `run` 이 던지면(엔진 장애 · 지금 비밀번호 틀림 등) 자리를 돌려주고 그대로 던진다 — 코드는 쓰지 않은 것으로 둔다
|
||||
*/
|
||||
async check<T>(
|
||||
source: CodeSource,
|
||||
code: string,
|
||||
run: () => Promise<CodeCheck<T>>,
|
||||
): Promise<T> {
|
||||
const ticket = this.attempts.beginCode(source);
|
||||
let key: string;
|
||||
try {
|
||||
key = codeKey(source.email, code);
|
||||
if (this.isUsedKey(key) || this.pendingCodes.has(key)) {
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-reused');
|
||||
}
|
||||
} catch (error) {
|
||||
// 차지한 실패 자리를 반드시 끝낸다 (이미 fail 했으면 두 번째 부름은 무시된다) — 코드가 글이 아닌 경우 등
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.add(key);
|
||||
|
||||
let result: CodeCheck<T>;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (error) {
|
||||
this.pendingCodes.delete(key);
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.delete(key);
|
||||
switch (result.kind) {
|
||||
case 'accepted':
|
||||
this.rememberKey(key);
|
||||
ticket.succeed();
|
||||
return result.value;
|
||||
case 'unused':
|
||||
ticket.abort();
|
||||
return result.value;
|
||||
case 'rejected':
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-rejected');
|
||||
}
|
||||
}
|
||||
|
||||
/** 성공에 쓴 코드를 기억한다 (check 가 받은 코드를 스스로 기억한다 — 테스트 · 바깥에서 미리 막을 때) */
|
||||
remember(email: string, code: string, now: number = Date.now()): void {
|
||||
this.rememberKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 2분 안에 성공에 쓴 코드인가 */
|
||||
isUsed(email: string, code: string, now: number = Date.now()): boolean {
|
||||
return this.isUsedKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 지금 확인 중인 코드인가 (테스트) */
|
||||
isPending(email: string, code: string): boolean {
|
||||
return this.pendingCodes.has(codeKey(email, code));
|
||||
}
|
||||
|
||||
private rememberKey(key: string, now: number = Date.now()): void {
|
||||
// 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다)
|
||||
this.usedCodes.delete(key);
|
||||
this.usedCodes.set(key, now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
}
|
||||
|
||||
private isUsedKey(key: string, now: number = Date.now()): boolean {
|
||||
const until = this.usedCodes.get(key);
|
||||
if (until === undefined) return false;
|
||||
if (until <= now) {
|
||||
this.usedCodes.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 지난 기억을 지운다 — 넣은 차례 = 끝나는 차례(기억 시간이 모두 같다) */
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [key, until] of this.usedCodes) {
|
||||
if (until > now) break;
|
||||
this.usedCodes.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private rejected(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_006', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function codeKey(email: string, code: string): string {
|
||||
return createHash('sha256')
|
||||
.update(email)
|
||||
.update('\0')
|
||||
.update(code)
|
||||
.digest('base64url');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
|
||||
import { TWO_FACTOR_ENROLLMENT_TTL_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 등록을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/** 기다리는 등록 하나 — 비밀은 켤 때까지 여기에만 있다 */
|
||||
export interface TwoFactorEnrollment {
|
||||
readonly id: string;
|
||||
readonly secret: Buffer;
|
||||
/** 끝나는 때 (epoch ms) */
|
||||
readonly expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollments {
|
||||
private readonly pending = new Map<string, TwoFactorEnrollment>();
|
||||
|
||||
/** 등록을 둔다 — 앞의 등록은 버린다 */
|
||||
put(email: string, enrollment: TwoFactorEnrollment): void {
|
||||
this.forget(email);
|
||||
this.pending.set(email, enrollment);
|
||||
}
|
||||
|
||||
/** 그 id 의 기다리는 등록 — 없거나 · 시간이 지났거나 · 더 새 등록으로 바뀌었으면 null */
|
||||
find(
|
||||
email: string,
|
||||
id: string,
|
||||
now: number = Date.now(),
|
||||
): TwoFactorEnrollment | null {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return null;
|
||||
if (enrollment.expiresAt <= now) {
|
||||
this.forget(email);
|
||||
return null;
|
||||
}
|
||||
return enrollment.id === id ? enrollment : null;
|
||||
}
|
||||
|
||||
/** 등록을 버린다 — 비밀 바이트도 지운다 */
|
||||
forget(email: string): void {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return;
|
||||
enrollment.secret.fill(0);
|
||||
this.pending.delete(email);
|
||||
}
|
||||
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [email, enrollment] of this.pending) {
|
||||
if (enrollment.expiresAt <= now) this.forget(email);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
}
|
||||
|
||||
/** 등록이 끝나는 때 — 지금부터 10분 */
|
||||
export function enrollmentExpiry(now: number): number {
|
||||
return now + TWO_FACTOR_ENROLLMENT_TTL_MS;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
|
||||
/**
|
||||
* 같은 직원의 2단계 인증 바꾸기는 한 번에 하나 (계약 5절 "2단계 인증") — 등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기.
|
||||
* 처리 중이면 기다리지 않고 429 REQ_001 (로그인의 진행 중 잠금과 같은 결 — 버튼을 두 번 누른 경우 등).
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorLocks {
|
||||
private readonly busy = new Set<string>();
|
||||
|
||||
/** 잠금을 잡고 일을 하고 푼다 — 잡혀 있으면 REQ_001 (잡는 확인과 넣기 사이에 await 가 없다) */
|
||||
async run<T>(email: string, work: () => Promise<T>): Promise<T> {
|
||||
const release = this.acquire(email);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/** 잠금을 잡는다 → 푸는 함수 (몇 번 불러도 한 번만 푼다). 잡혀 있으면 REQ_001 */
|
||||
acquire(email: string): () => void {
|
||||
if (this.busy.has(email)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'two-factor-change-in-progress',
|
||||
email: maskEmail(email),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.busy.add(email);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.busy.delete(email);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
|
||||
/**
|
||||
* 웹메일에서 2단계 인증을 켠 때 (계약 5절 "2단계 인증") — 직원 주소마다 한 줄.
|
||||
* **엔진이 기준이다** — 켜짐 여부는 엔진(x:AccountPassword 의 otpUrl)으로 보고, 여기는 켠 때만 둔다.
|
||||
* 엔진에서 꺼졌으면 지운다. 엔진 관리 화면에서 켠 2단계 인증은 줄이 없다(enabledAt null). 비밀은 두지 않는다
|
||||
*/
|
||||
@Entity({ name: 'two_factor_settings' })
|
||||
export class TwoFactorSettingEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@Column({ name: 'enabled_at', type: 'timestamptz' })
|
||||
enabledAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
TwoFactorActivationDto,
|
||||
TwoFactorDeactivationDto,
|
||||
TwoFactorEnrollmentDto,
|
||||
TwoFactorStatusDto,
|
||||
} from './dto/two-factor.dto';
|
||||
import { TwoFactorService, type TwoFactorStatus } from './two-factor.service';
|
||||
|
||||
function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto {
|
||||
return {
|
||||
isEnabled: status.isEnabled,
|
||||
enabledAt: status.enabledAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2단계 인증 (계약 5절 "2단계 인증") — 판단은 모두 TwoFactorService 가 한다. 요청 수 상한은 전역 기본값 —
|
||||
// 코드 · 비밀번호 맞혀 보기는 로그인과 같은 실패 한도(코드 15분 5회 · 24시간 20회, 비밀번호 15분 10회)가 막는다
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
@Controller('two-factor')
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactor: TwoFactorService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 상태',
|
||||
description:
|
||||
'엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async status(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(await this.twoFactor.getStatus(user.email));
|
||||
}
|
||||
|
||||
@Post('enrollments')
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
|
||||
description:
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorEnrollmentDto, {
|
||||
status: 201,
|
||||
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다')
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async enroll(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorEnrollmentDto> {
|
||||
const enrollment = await this.twoFactor.startEnrollment(user.email);
|
||||
return {
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
secret: enrollment.secret,
|
||||
otpauthUrl: enrollment.otpauthUrl,
|
||||
expiresAt: enrollment.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('activation')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 켜기 — 첫 코드와 지금 비밀번호',
|
||||
description:
|
||||
'코드를 등록의 비밀로 먼저 확인한다(앞뒤 30초) — 엔진은 등록 때 코드를 보지 않는다. 둘 다 틀렸으면 코드부터 알린다(AUTH_006). ' +
|
||||
'그다음 지금 비밀번호를 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다) 엔진에 넣는다. 켠 코드는 2분 동안 다시 받지 않는다. ' +
|
||||
'다른 곳의 웹메일 로그인은 끊지 않는다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '켰다' })
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐 — 화면은 처음부터 다시',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 비밀번호 실패 한도 · 처리 중',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 켜졌으면 성공으로 답한다)',
|
||||
)
|
||||
async activate(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorActivationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(
|
||||
await this.twoFactor.activate(
|
||||
user.email,
|
||||
{
|
||||
enrollmentId: dto.enrollmentId,
|
||||
otpCode: dto.otpCode,
|
||||
currentPassword: dto.currentPassword,
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('deactivation')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드',
|
||||
description:
|
||||
'비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' +
|
||||
'코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, {
|
||||
description: '껐다 (또는 이미 꺼져 있었다)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 꺼졌으면 성공으로 답한다)',
|
||||
)
|
||||
async deactivate(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorDeactivationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(
|
||||
await this.twoFactor.deactivate(
|
||||
user.email,
|
||||
{ currentPassword: dto.currentPassword, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,780 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_LIMIT,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { CredentialCheck } from '@/modules/mail/mail.types';
|
||||
import { totpCode, totpCounterAt } from '@/shared/utils/totp';
|
||||
import type {
|
||||
AccountSecurityProvider,
|
||||
TwoFactorDisableResult,
|
||||
TwoFactorEnableResult,
|
||||
} from './account-security.provider';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import type { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
import { TwoFactorService } from './two-factor.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const PASSWORD = 'correct-horse-battery';
|
||||
const IP = '203.0.113.10';
|
||||
const VALID: CredentialCheck = { kind: 'valid', account: ARA };
|
||||
const INVALID: CredentialCheck = { kind: 'invalid' };
|
||||
const TWO_FACTOR: CredentialCheck = { kind: 'two-factor-required' };
|
||||
|
||||
const mailSettings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set(['admin@arasystem.test']),
|
||||
directoryAccount: null,
|
||||
};
|
||||
|
||||
/** base32(RFC 4648) → 바이트 — 등록 응답의 비밀로 코드를 만들 때 */
|
||||
function decodeBase32(text: string): Buffer {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const bytes: number[] = [];
|
||||
for (const char of text) {
|
||||
value = (value << 5) | alphabet.indexOf(char);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
/** 손으로 푸는 약속 — 엔진 호출이 걸려 있는 동안 다른 요청을 보낸다 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** 조건이 될 때까지 이벤트 루프를 돌린다 */
|
||||
async function until(condition: () => boolean): Promise<void> {
|
||||
for (let turn = 0; turn < 1_000 && !condition(); turn += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
if (!condition()) throw new Error('조건이 되지 않았다');
|
||||
}
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 두 Map 기반 설정 표 대역 */
|
||||
function fakeSettings() {
|
||||
const rows = new Map<string, TwoFactorSettingEntity>();
|
||||
return {
|
||||
rows,
|
||||
repository: {
|
||||
findOneBy: jest.fn(({ userEmail }: { userEmail: string }) =>
|
||||
Promise.resolve(rows.get(userEmail) ?? null),
|
||||
),
|
||||
upsert: jest.fn((row: TwoFactorSettingEntity) => {
|
||||
rows.set(row.userEmail, { ...row });
|
||||
return Promise.resolve();
|
||||
}),
|
||||
delete: jest.fn(({ userEmail }: { userEmail: string }) => {
|
||||
rows.delete(userEmail);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', () => {
|
||||
let engineEnabled: boolean;
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor'
|
||||
>
|
||||
>;
|
||||
let mail: { verifyCredentials: jest.Mock };
|
||||
let table: ReturnType<typeof fakeSettings>;
|
||||
let codes: TwoFactorCodeGuard;
|
||||
let locks: TwoFactorLocks;
|
||||
let service: TwoFactorService;
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
engineEnabled = false;
|
||||
provider = {
|
||||
isTwoFactorEnabled: jest.fn<Promise<boolean>, [string]>(() =>
|
||||
Promise.resolve(engineEnabled),
|
||||
),
|
||||
enableTwoFactor: jest.fn<
|
||||
Promise<TwoFactorEnableResult>,
|
||||
[string, string, string]
|
||||
>(() => {
|
||||
engineEnabled = true;
|
||||
return Promise.resolve('enabled');
|
||||
}),
|
||||
disableTwoFactor: jest.fn<
|
||||
Promise<TwoFactorDisableResult>,
|
||||
[string, string, string]
|
||||
>(() => {
|
||||
engineEnabled = false;
|
||||
return Promise.resolve('disabled');
|
||||
}),
|
||||
};
|
||||
mail = {
|
||||
verifyCredentials: jest.fn(() =>
|
||||
Promise.resolve(engineEnabled ? TWO_FACTOR : VALID),
|
||||
),
|
||||
};
|
||||
table = fakeSettings();
|
||||
const attempts = new LoginAttemptService();
|
||||
codes = new TwoFactorCodeGuard(attempts);
|
||||
locks = new TwoFactorLocks();
|
||||
service = new TwoFactorService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
mail as unknown as MailProvider,
|
||||
mailSettings,
|
||||
table.repository as unknown as Repository<TwoFactorSettingEntity>,
|
||||
new TwoFactorEnrollments(),
|
||||
codes,
|
||||
locks,
|
||||
attempts,
|
||||
);
|
||||
logs = [];
|
||||
errors = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
/** 등록하고 그 비밀로 지금 코드를 만든다 */
|
||||
const enroll = async () => {
|
||||
const enrollment = await service.startEnrollment(ARA);
|
||||
const secret = decodeBase32(enrollment.secret);
|
||||
return {
|
||||
enrollment,
|
||||
code: (offset = 0) =>
|
||||
totpCode(secret, totpCounterAt(Date.now()) + offset),
|
||||
};
|
||||
};
|
||||
|
||||
it('등록 — 160비트 비밀(base32 32자)과 발급자 「ARA SYSTEM」의 otpauth 주소, 10분', async () => {
|
||||
const before = Date.now();
|
||||
const { enrollment } = await enroll();
|
||||
|
||||
expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/);
|
||||
expect(enrollment.otpauthUrl).toBe(
|
||||
`otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.test?secret=${enrollment.secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
|
||||
);
|
||||
expect(enrollment.expiresAt.getTime()).toBeGreaterThanOrEqual(
|
||||
before + 10 * 60_000,
|
||||
);
|
||||
// 비밀 · 주소는 로그에 남지 않는다
|
||||
expect(logs.join('\n')).not.toContain(enrollment.secret);
|
||||
});
|
||||
|
||||
it('켜기 — 코드를 등록의 비밀로 먼저 확인하고 지금 비밀번호를 본 뒤 엔진에 otpauth 주소를 넣는다, 켠 때를 둔다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
|
||||
const status = await service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
);
|
||||
|
||||
expect(status.isEnabled).toBe(true);
|
||||
expect(status.enabledAt).toBeInstanceOf(Date);
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
|
||||
expect(provider.enableTwoFactor).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
enrollment.otpauthUrl,
|
||||
);
|
||||
expect(table.rows.get(ARA)?.enabledAt).toEqual(status.enabledAt);
|
||||
expect(logs).toContain('2단계 인증을 켰습니다: a***@arasystem.test');
|
||||
await expect(service.getStatus(ARA)).resolves.toEqual(status);
|
||||
});
|
||||
|
||||
it('앞뒤 한 단계(±30초)의 코드도 받는다 · 켠 코드는 2분 동안 다시 받지 않는다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const used = code(-1);
|
||||
await service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: used,
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
);
|
||||
expect(codes.isUsed(ARA, used)).toBe(true);
|
||||
});
|
||||
|
||||
it('코드가 틀리면 400 AUTH_006 — 둘 다 틀렸어도 코드부터 알린다(비밀번호를 묻지 않는다), 엔진에 넣지 않는다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const wrong = code() === '000000' ? '111111' : '000000';
|
||||
|
||||
const failure = await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: wrong,
|
||||
currentPassword: 'wrong',
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
);
|
||||
|
||||
expect(failure?.code).toBe('AUTH_006');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.enableTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 400 AUTH_005 — 로그인 실패 한도로 센다 · 앱 비밀번호 모양은 묻지 않고 AUTH_005(세지 않는다)', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
const { enrollment, code } = await enroll();
|
||||
const failure = await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: 'wrong',
|
||||
},
|
||||
{ ip: `198.51.100.${i}` },
|
||||
),
|
||||
);
|
||||
expect(failure?.code).toBe('AUTH_005');
|
||||
}
|
||||
const { enrollment, code } = await enroll();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
|
||||
mail.verifyCredentials.mockClear();
|
||||
const app = await enroll();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: app.enrollment.enrollmentId,
|
||||
otpCode: app.code(),
|
||||
currentPassword: `app_${'a'.repeat(36)}`,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.context,
|
||||
).toMatchObject({ reason: 'app-password-shape' });
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('등록이 없음 · 다른 id(더 새 등록으로 바뀜) · 10분 지남이면 409 AUTH_008', async () => {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: '00000000-0000-4000-8000-000000000000',
|
||||
otpCode: '123456',
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
|
||||
const first = await enroll();
|
||||
await enroll();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: first.enrollment.enrollmentId,
|
||||
otpCode: first.code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
});
|
||||
|
||||
it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
engineEnabled = true;
|
||||
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => {
|
||||
const first = await enroll();
|
||||
provider.enableTwoFactor.mockImplementationOnce(() => {
|
||||
engineEnabled = true;
|
||||
return Promise.resolve('unknown' as const);
|
||||
});
|
||||
await expect(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: first.enrollment.enrollmentId,
|
||||
otpCode: first.code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toMatchObject({ isEnabled: true });
|
||||
|
||||
engineEnabled = false;
|
||||
const second = await enroll();
|
||||
provider.enableTwoFactor.mockResolvedValueOnce('unknown');
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: second.enrollment.enrollmentId,
|
||||
otpCode: second.code(1),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('켜는 동안(엔진에 넣는 중) 같은 코드는 다른 요청이 쓰지 못한다 — 확인 중이라 400 AUTH_006 (로그인 · 비밀번호 바꾸기와 같은 기억)', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const used = code();
|
||||
const engine = deferred<TwoFactorEnableResult>();
|
||||
provider.enableTwoFactor.mockImplementationOnce(() => engine.promise);
|
||||
|
||||
const activation = service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: used,
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
);
|
||||
await until(() => provider.enableTwoFactor.mock.calls.length === 1);
|
||||
|
||||
// 같은 코드로 들어온 다른 요청(같은 순간의 로그인 등) — 확인(run) 없이 막힌다
|
||||
const other = jest.fn(() =>
|
||||
Promise.resolve({ kind: 'accepted' as const, value: undefined }),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(codes.check({ email: ARA, ip: IP }, used, other)))?.code,
|
||||
).toBe('AUTH_006');
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
|
||||
engineEnabled = true;
|
||||
engine.resolve('enabled');
|
||||
await expect(activation).resolves.toMatchObject({ isEnabled: true });
|
||||
expect(codes.isUsed(ARA, used)).toBe(true);
|
||||
});
|
||||
|
||||
it('코드는 맞고 지금 비밀번호가 틀리면 AUTH_005 — 그 코드는 쓰지 않은 것으로 남아 비밀번호만 고쳐 같은 코드로 켤 수 있다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const used = code();
|
||||
mail.verifyCredentials.mockResolvedValueOnce(INVALID);
|
||||
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: used,
|
||||
currentPassword: 'wrong',
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_005');
|
||||
expect(codes.isUsed(ARA, used)).toBe(false);
|
||||
expect(provider.enableTwoFactor).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: used,
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toMatchObject({ isEnabled: true });
|
||||
});
|
||||
|
||||
it('엔진에 켠 뒤 켠 때 기록(DB)이 실패해도 성공으로 답한다(enabledAt null · error 로그) — 등록 비밀은 버리고 코드는 기억한다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const used = code();
|
||||
table.repository.upsert.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: used,
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: true, enabledAt: null });
|
||||
expect(errors.join('\n')).toMatch(
|
||||
/\[SYS_001\].*켠 때를 기록하지 못했습니다.*a\*\*\*@arasystem\.test/,
|
||||
);
|
||||
expect(codes.isUsed(ARA, used)).toBe(true);
|
||||
|
||||
// 등록은 이미 버렸다 — 같은 등록으로 다시 켜지 못한다
|
||||
engineEnabled = false;
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(1),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
});
|
||||
|
||||
it('클라이언트 IP 를 모르면(빈 값 · 없음) 한 묶음(unknown)으로 센다 — 오류 없이 켠다', async () => {
|
||||
const first = await enroll();
|
||||
await expect(
|
||||
service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: first.enrollment.enrollmentId,
|
||||
otpCode: first.code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: undefined },
|
||||
),
|
||||
).resolves.toMatchObject({ isEnabled: true });
|
||||
await expect(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '654321' },
|
||||
{ ip: '' },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: false, enabledAt: null });
|
||||
});
|
||||
|
||||
it('같은 직원의 바꾸기가 처리 중이면 429 REQ_001', async () => {
|
||||
const release = locks.acquire(ARA);
|
||||
try {
|
||||
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
describe('끄기', () => {
|
||||
beforeEach(() => {
|
||||
engineEnabled = true;
|
||||
table.rows.set(ARA, { userEmail: ARA, enabledAt: new Date() });
|
||||
});
|
||||
|
||||
it('비밀번호를 본인 Basic 으로 먼저 보고(402) 엔진에 코드와 함께 끈다 — 켠 때를 지우고 코드를 2분 기억한다', async () => {
|
||||
const status = await service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
);
|
||||
|
||||
expect(status).toEqual({ isEnabled: false, enabledAt: null });
|
||||
expect(provider.disableTwoFactor).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
'123456',
|
||||
);
|
||||
expect(table.rows.has(ARA)).toBe(false);
|
||||
expect(codes.isUsed(ARA, '123456')).toBe(true);
|
||||
expect(logs).toContain('2단계 인증을 껐습니다: a***@arasystem.test');
|
||||
});
|
||||
|
||||
it('끄는 동안(엔진에 보내는 중) 같은 코드는 다른 요청이 쓰지 못한다 — 확인 중이라 400 AUTH_006', async () => {
|
||||
const engine = deferred<TwoFactorDisableResult>();
|
||||
provider.disableTwoFactor.mockImplementationOnce(() => engine.promise);
|
||||
|
||||
const deactivation = service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
);
|
||||
await until(() => provider.disableTwoFactor.mock.calls.length === 1);
|
||||
|
||||
const other = jest.fn(() =>
|
||||
Promise.resolve({ kind: 'accepted' as const, value: undefined }),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(codes.check({ email: ARA, ip: IP }, '123456', other)))
|
||||
?.code,
|
||||
).toBe('AUTH_006');
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
|
||||
engineEnabled = false;
|
||||
engine.resolve('disabled');
|
||||
await expect(deactivation).resolves.toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('결과를 모르게 끝나면(unknown) 엔진을 다시 읽는다', () => {
|
||||
it('꺼졌으면 성공 — 켠 때를 지우고 코드를 기억한다', async () => {
|
||||
provider.disableTwoFactor.mockImplementationOnce(() => {
|
||||
engineEnabled = false;
|
||||
return Promise.resolve('unknown' as const);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: false, enabledAt: null });
|
||||
expect(provider.isTwoFactorEnabled).toHaveBeenCalledWith(ARA);
|
||||
expect(table.rows.has(ARA)).toBe(false);
|
||||
expect(codes.isUsed(ARA, '123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('아직 켜져 있으면 503 MAIL_001 — 켠 때는 그대로, 코드는 쓰지 않은 것으로(기억하지도 세지도 않는다)', async () => {
|
||||
provider.disableTwoFactor.mockResolvedValue('unknown');
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 1; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('MAIL_001');
|
||||
}
|
||||
expect(table.rows.has(ARA)).toBe(true);
|
||||
expect(codes.isUsed(ARA, '123456')).toBe(false);
|
||||
|
||||
provider.disableTwoFactor.mockImplementation(() => {
|
||||
engineEnabled = false;
|
||||
return Promise.resolve('disabled' as const);
|
||||
});
|
||||
await expect(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: false, enabledAt: null });
|
||||
});
|
||||
|
||||
it('다시 읽기도 실패하면 그 실패(MAIL_001)를 그대로 — 코드는 쓰지 않은 것으로', async () => {
|
||||
provider.disableTwoFactor.mockResolvedValueOnce('unknown');
|
||||
provider.isTwoFactorEnabled.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001', {
|
||||
context: { reason: 'engine-down' },
|
||||
}),
|
||||
);
|
||||
|
||||
const failure = await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
);
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(failure?.context).toMatchObject({ reason: 'engine-down' });
|
||||
expect(codes.isUsed(ARA, '123456')).toBe(false);
|
||||
expect(table.rows.has(ARA)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진에서 끈 뒤 켠 때 기록 지우기가 실패해도 200 — error 로그만 (다음 상태 읽기가 다시 맞춘다)', async () => {
|
||||
table.repository.delete.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: false, enabledAt: null });
|
||||
expect(errors.join('\n')).toMatch(
|
||||
/\[SYS_001\].*기록을 지우지 못했습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it('엔진이 코드를 받지 않으면 400 AUTH_006 (코드 실패로 센다 — 15분 5회)', async () => {
|
||||
provider.disableTwoFactor.mockResolvedValue('code-rejected');
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '000000' },
|
||||
{ ip: `198.51.100.${i}` },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 400 AUTH_005 — 엔진에 끄기를 보내지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: 'wrong', otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_005');
|
||||
expect(provider.disableTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('이미 꺼져 있으면(본인 Basic 200) 200 같은 결과 — 켠 때 기록도 지운다', async () => {
|
||||
engineEnabled = false;
|
||||
await expect(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
).resolves.toEqual({ isEnabled: false, enabledAt: null });
|
||||
expect(provider.disableTwoFactor).not.toHaveBeenCalled();
|
||||
expect(table.rows.has(ARA)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('상태 — 엔진에서 꺼졌으면 켠 때를 지운다 · 엔진 관리 화면에서 켰으면 enabledAt null', async () => {
|
||||
table.rows.set(ARA, { userEmail: ARA, enabledAt: new Date() });
|
||||
await expect(service.getStatus(ARA)).resolves.toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
});
|
||||
expect(table.rows.has(ARA)).toBe(false);
|
||||
|
||||
engineEnabled = true;
|
||||
await expect(service.getStatus(ARA)).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('웹메일로 다루지 않는 계정은 엔진에 묻지 않고 AUTH_001', async () => {
|
||||
expect(
|
||||
(await failureOf(service.getStatus('admin@arasystem.test')))?.code,
|
||||
).toBe('AUTH_001');
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
isEmployeeAddress,
|
||||
normalizeEmail,
|
||||
} from '@/modules/auth/employee-address';
|
||||
import {
|
||||
LoginAttemptService,
|
||||
type LoginAttemptTicket,
|
||||
} from '@/modules/auth/login-attempt.service';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { buildOtpauthUrl, encodeBase32, verifyTotp } from '@/shared/utils/totp';
|
||||
import {
|
||||
TWO_FACTOR_ISSUER,
|
||||
TWO_FACTOR_SECRET_BYTES,
|
||||
} from './account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_PROVIDER,
|
||||
type AccountSecurityProvider,
|
||||
} from './account-security.provider';
|
||||
import { isAppPasswordShape } from './app-password';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import {
|
||||
TwoFactorEnrollments,
|
||||
enrollmentExpiry,
|
||||
} from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
|
||||
/** GET /api/two-factor 의 모양 — 켜기 · 끄기의 응답도 같다 */
|
||||
export interface TwoFactorStatus {
|
||||
readonly isEnabled: boolean;
|
||||
/** 웹메일에서 켠 때 — 엔진 관리 화면에서 켰으면 null */
|
||||
readonly enabledAt: Date | null;
|
||||
}
|
||||
|
||||
/** 등록 — 비밀 · otpauth 주소는 이 응답에만 (no-store) */
|
||||
export interface TwoFactorEnrollmentView {
|
||||
readonly enrollmentId: string;
|
||||
/** base32 32자 */
|
||||
readonly secret: string;
|
||||
readonly otpauthUrl: string;
|
||||
readonly expiresAt: Date;
|
||||
}
|
||||
|
||||
/** 요청한 클라이언트 — 실패 한도의 IP (모르면 빈 값 · undefined — 'unknown' 한 묶음으로 센다) */
|
||||
export interface SecurityClient {
|
||||
readonly ip: string | undefined;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다).
|
||||
*
|
||||
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008
|
||||
* - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다).
|
||||
* 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다,
|
||||
* 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다.
|
||||
* 이 셋을 한 번의 코드 확인(TwoFactorCodeGuard.check) 안에서 한다 — 그동안 같은 코드는 "확인 중"이라 다른 요청이 쓰지 못하고,
|
||||
* 비밀번호가 틀려 끝나면 코드는 쓰지 않은 것으로 둔다(비밀번호만 고쳐 같은 코드로 다시 보낼 수 있게). 엔진에 켜지면 등록 비밀을 버리고 코드를 기억한다.
|
||||
* 켠 때 기록(DB)은 그 뒤다 — 실패해도 error 로그만 남기고 성공으로 답한다(엔진이 기준이다 — 응답의 enabledAt 은 null)
|
||||
* - 끄기: 지금 비밀번호를 본인 Basic 으로 먼저(402 = 켜짐 · 비밀번호 맞음, 200 = 이미 꺼짐 → 그대로 200) → 다시 쓴 코드 확인 →
|
||||
* 대리 접근으로 코드와 함께 끈다(엔진이 코드를 확인한다). 켠 때 기록 지우기가 실패해도 error 로그만(다음 상태 읽기가 다시 맞춘다)
|
||||
* - 같은 직원의 바꾸기는 한 번에 하나(TwoFactorLocks — REQ_001). 결과를 모르게 끝나면 엔진을 다시 읽어 맞춘다(바뀌었으면 성공으로)
|
||||
* - 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 다(세지 않는다)
|
||||
* - 켜고 끄는 일은 웹메일 세션을 끊지 않는다 (계약 5절 "정한 것" 3)
|
||||
* 비밀 · otpauth 주소 · 코드 · 비밀번호는 로그에 남기지 않는다. 켜기 · 끄기는 info 에 가린 주소만
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorService {
|
||||
private readonly logger = new Logger(TwoFactorService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ACCOUNT_SECURITY_PROVIDER)
|
||||
private readonly provider: AccountSecurityProvider,
|
||||
@Inject(MAIL_PROVIDER) private readonly mail: MailProvider,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
@InjectRepository(TwoFactorSettingEntity)
|
||||
private readonly settings: Repository<TwoFactorSettingEntity>,
|
||||
private readonly enrollments: TwoFactorEnrollments,
|
||||
private readonly codes: TwoFactorCodeGuard,
|
||||
private readonly locks: TwoFactorLocks,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
) {}
|
||||
|
||||
/** GET /api/two-factor — 엔진에서 꺼졌으면 켠 때 기록을 지운다 */
|
||||
async getStatus(user: string): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const enabled = await this.provider.isTwoFactorEnabled(address);
|
||||
return this.reconcile(address, enabled);
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments — 꺼져 있을 때만 */
|
||||
async startEnrollment(user: string): Promise<TwoFactorEnrollmentView> {
|
||||
const address = this.requireEmployee(user);
|
||||
return this.locks.run(address, async () => {
|
||||
if (await this.provider.isTwoFactorEnabled(address)) {
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-already-enabled',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
const now = Date.now();
|
||||
const enrollment = {
|
||||
id: randomUUID(),
|
||||
secret: randomBytes(TWO_FACTOR_SECRET_BYTES),
|
||||
expiresAt: enrollmentExpiry(now),
|
||||
};
|
||||
this.enrollments.put(address, enrollment);
|
||||
const secret = encodeBase32(enrollment.secret);
|
||||
return {
|
||||
enrollmentId: enrollment.id,
|
||||
secret,
|
||||
otpauthUrl: this.otpauthUrl(address, secret),
|
||||
expiresAt: new Date(enrollment.expiresAt),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
async activate(
|
||||
user: string,
|
||||
input: {
|
||||
readonly enrollmentId: string;
|
||||
readonly otpCode: string;
|
||||
readonly currentPassword: string;
|
||||
},
|
||||
client: SecurityClient,
|
||||
): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
const enrollment = this.enrollments.find(address, input.enrollmentId);
|
||||
if (!enrollment) {
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-enrollment-missing',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isAppPasswordShape(input.currentPassword)) {
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
}
|
||||
|
||||
// 지금 비밀번호의 실패 한도 — 코드가 틀리면 비밀번호는 묻지 않고 돌려준다(finally 의 abort)
|
||||
const ticket = this.attempts.begin({ email: address, ip });
|
||||
try {
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.otpCode,
|
||||
async () => {
|
||||
// 1) 코드를 등록의 비밀로 먼저 — 둘 다 틀렸으면 코드부터 알린다
|
||||
if (
|
||||
verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null
|
||||
) {
|
||||
return { kind: 'rejected' as const };
|
||||
}
|
||||
// 2) 지금 비밀번호 — 틀리면 AUTH_005 (던지면 코드는 쓰지 않은 것으로 남는다)
|
||||
await this.assertCurrentPassword(
|
||||
address,
|
||||
input.currentPassword,
|
||||
ticket,
|
||||
);
|
||||
// 3) 엔진에 넣는다 — 엔진은 코드를 보지 않고 저장한다
|
||||
await this.enableInEngine(
|
||||
address,
|
||||
input.currentPassword,
|
||||
encodeBase32(enrollment.secret),
|
||||
ticket,
|
||||
);
|
||||
// 엔진에 켜졌다 — 등록 비밀은 지금 버린다(뒤의 기록이 실패해도 메모리에 남지 않게)
|
||||
this.enrollments.forget(address);
|
||||
return { kind: 'accepted' as const, value: undefined };
|
||||
},
|
||||
);
|
||||
ticket.succeed();
|
||||
} finally {
|
||||
ticket.abort();
|
||||
}
|
||||
|
||||
const enabledAt = await this.recordEnabledAt(address);
|
||||
this.logger.log(`2단계 인증을 켰습니다: ${maskEmail(address)}`);
|
||||
return { isEnabled: true, enabledAt };
|
||||
});
|
||||
}
|
||||
|
||||
/** 엔진에 otpauth 주소를 넣는다 — 거절이면 AUTH_008, 결과를 모르면 다시 읽어 켜지지 않았으면 MAIL_001 */
|
||||
private async enableInEngine(
|
||||
address: string,
|
||||
currentPassword: string,
|
||||
secret: string,
|
||||
ticket: LoginAttemptTicket,
|
||||
): Promise<void> {
|
||||
const result = await this.provider.enableTwoFactor(
|
||||
address,
|
||||
currentPassword,
|
||||
this.otpauthUrl(address, secret),
|
||||
);
|
||||
if (result === 'refused') {
|
||||
// 비밀번호는 바로 앞에서 맞았다 — 그사이 다른 곳에서 비밀번호 · 2단계 인증이 바뀌었다
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-enable-refused',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (result === 'unknown' && !(await this.engineSays(address, true))) {
|
||||
ticket.abort();
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'two-factor-enable-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 켠 때를 둔다 → 그 때. 실패하면 error 로그만 남기고 null — 엔진에는 이미 켜졌다(엔진이 기준이다) */
|
||||
private async recordEnabledAt(address: string): Promise<Date | null> {
|
||||
const enabledAt = new Date();
|
||||
try {
|
||||
await this.settings.upsert({ userEmail: address, enabledAt }, [
|
||||
'userEmail',
|
||||
]);
|
||||
return enabledAt;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 2단계 인증을 켠 때를 기록하지 못했습니다(엔진에는 켜졌다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과) */
|
||||
async deactivate(
|
||||
user: string,
|
||||
input: { readonly currentPassword: string; readonly otpCode: string },
|
||||
client: SecurityClient,
|
||||
): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
if (isAppPasswordShape(input.currentPassword)) {
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
}
|
||||
|
||||
const ticket = this.attempts.begin({ email: address, ip });
|
||||
try {
|
||||
const check = await this.mail.verifyCredentials(
|
||||
address,
|
||||
input.currentPassword,
|
||||
);
|
||||
if (check.kind === 'invalid') {
|
||||
ticket.fail();
|
||||
throw this.currentPasswordRejected(
|
||||
address,
|
||||
'current-password-rejected',
|
||||
);
|
||||
}
|
||||
if (check.kind === 'valid') {
|
||||
// 엔진에서 이미 꺼져 있다 — 같은 결과로 답한다
|
||||
ticket.succeed();
|
||||
await this.forgetEnabledAt(address);
|
||||
return { isEnabled: false, enabledAt: null };
|
||||
}
|
||||
// 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
|
||||
ticket.verified();
|
||||
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.otpCode,
|
||||
async () => {
|
||||
const outcome = await this.provider.disableTwoFactor(
|
||||
address,
|
||||
input.currentPassword,
|
||||
input.otpCode,
|
||||
);
|
||||
if (outcome === 'code-rejected') {
|
||||
return { kind: 'rejected' as const };
|
||||
}
|
||||
// 결과를 모르면 엔진을 다시 읽는다 — 꺼졌으면 성공(코드를 쓴 것으로), 아니면 MAIL_001(코드는 쓰지 않은 것으로)
|
||||
if (
|
||||
outcome === 'unknown' &&
|
||||
!(await this.engineSays(address, false))
|
||||
) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'two-factor-disable-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { kind: 'accepted' as const, value: undefined };
|
||||
},
|
||||
);
|
||||
ticket.succeed();
|
||||
} finally {
|
||||
ticket.abort();
|
||||
}
|
||||
|
||||
await this.forgetEnabledAt(address);
|
||||
this.logger.log(`2단계 인증을 껐습니다: ${maskEmail(address)}`);
|
||||
return { isEnabled: false, enabledAt: null };
|
||||
});
|
||||
}
|
||||
|
||||
/** 엔진의 켜짐에 기록을 맞춘다 — 꺼졌으면 켠 때를 지운다 */
|
||||
private async reconcile(
|
||||
address: string,
|
||||
enabled: boolean,
|
||||
): Promise<TwoFactorStatus> {
|
||||
const row = await this.settings.findOneBy({ userEmail: address });
|
||||
if (!enabled) {
|
||||
if (row) await this.settings.delete({ userEmail: address });
|
||||
return { isEnabled: false, enabledAt: null };
|
||||
}
|
||||
return { isEnabled: true, enabledAt: row?.enabledAt ?? null };
|
||||
}
|
||||
|
||||
/** 켠 때 기록을 지운다 — 실패해도 error 로그만(엔진에서는 꺼졌다 — 다음 상태 읽기가 다시 지운다) */
|
||||
private async forgetEnabledAt(address: string): Promise<void> {
|
||||
try {
|
||||
await this.settings.delete({ userEmail: address });
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 2단계 인증을 켠 때 기록을 지우지 못했습니다(엔진에서는 꺼졌다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 결과를 모르게 끝난 켜기 · 끄기 — 엔진을 다시 읽어 바라는 상태면 true (읽지 못하면 그 실패를 던진다) */
|
||||
private async engineSays(
|
||||
address: string,
|
||||
enabled: boolean,
|
||||
): Promise<boolean> {
|
||||
return (await this.provider.isTwoFactorEnabled(address)) === enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 켜기의 지금 비밀번호 — 본인 Basic 으로 확인한다. 틀리면 실패로 남기고 AUTH_005, 402(그사이 켜짐)면 AUTH_008.
|
||||
* 맞으면 아무것도 하지 않는다 (시도는 부르는 쪽이 끝낸다)
|
||||
*/
|
||||
private async assertCurrentPassword(
|
||||
address: string,
|
||||
password: string,
|
||||
ticket: LoginAttemptTicket,
|
||||
): Promise<void> {
|
||||
const check = await this.mail.verifyCredentials(address, password);
|
||||
if (check.kind === 'invalid') {
|
||||
ticket.fail();
|
||||
throw this.currentPasswordRejected(address, 'current-password-rejected');
|
||||
}
|
||||
if (check.kind === 'two-factor-required') {
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-enabled-elsewhere',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private otpauthUrl(address: string, secret: string): string {
|
||||
return buildOtpauthUrl({
|
||||
issuer: TWO_FACTOR_ISSUER,
|
||||
account: address,
|
||||
secret,
|
||||
});
|
||||
}
|
||||
|
||||
private currentPasswordRejected(
|
||||
address: string,
|
||||
reason: string,
|
||||
): BusinessException {
|
||||
return new BusinessException('AUTH_005', {
|
||||
context: { reason, email: maskEmail(address) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹메일로 다루는 직원 주소 — 관리자 · 게이트웨이 · 막은 계정은 엔진에 묻지 않고 AUTH_001.
|
||||
* 메일 엔진 설정이 없으면(비운영) MAIL_001 — 로그인 · 비밀번호 바꾸기와 같다
|
||||
*/
|
||||
private requireEmployee(user: string): string {
|
||||
const address = normalizeEmail(user);
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
if (domain === null) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
if (!isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'two-factor-not-allowed',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ const EMAIL_COLUMN_LENGTH = 254;
|
||||
const TOKEN_HASH_LENGTH = 64;
|
||||
/** 폐기 사유 길이 */
|
||||
const REVOKED_REASON_LENGTH = 32;
|
||||
/** IP 문자열 길이 — IPv6 최대 45자에 여유를 둔다 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
/** User-Agent 원문 길이 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
|
||||
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
|
||||
export type SessionRevokedReason =
|
||||
@@ -15,13 +19,17 @@ export type SessionRevokedReason =
|
||||
/** 비밀번호를 바꿨다 — 바꾼 세션 밖의 그 직원 세션 (계약 1절 "PUT /api/auth/password") */
|
||||
| 'password-changed'
|
||||
/** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */
|
||||
| 'password-change-uncertain';
|
||||
| 'password-change-uncertain'
|
||||
/** 직원이 로그인된 브라우저 목록에서 끊었다 — 하나 · 다른 곳 모두 (계약 5절 "로그인된 브라우저") */
|
||||
| 'ended-by-user';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
*
|
||||
* - 리프레시 토큰 원문은 저장하지 않는다. SHA-256 해시만 둔다 (DB 가 새어도 토큰을 만들 수 없다)
|
||||
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증 — 세션을 끊어도 만료까지는 유효하다)
|
||||
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증). 12단계부터 액세스 토큰에 세션 id(sid)를 싣고,
|
||||
* 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다)
|
||||
* - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null
|
||||
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
|
||||
*/
|
||||
@Entity({ name: 'auth_sessions' })
|
||||
@@ -30,6 +38,8 @@ export class AuthSessionEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 직원 주소 — 로그인된 브라우저 목록 · 다른 곳 모두 끊기가 이 열로 찾는다 (12단계에 인덱스를 더했다) */
|
||||
@Index('IDX_auth_sessions_user_email')
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
@@ -74,4 +84,17 @@ export class AuthSessionEntity {
|
||||
nullable: true,
|
||||
})
|
||||
revokedReason!: SessionRevokedReason | null;
|
||||
|
||||
/** 마지막으로 로그인 · 갱신한 클라이언트 IP (TRUST_PROXY 기준) — 12단계 전에 연 세션은 null */
|
||||
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
|
||||
ip!: string | null;
|
||||
|
||||
/** 마지막으로 로그인 · 갱신한 브라우저의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만 (shared/utils/user-agent.ts) */
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
length: USER_AGENT_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
userAgent!: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
/**
|
||||
* 로그인 세션 표와 끊은 세션 기억 — 인증 모듈과 메일 모듈(실시간 알림이 끊은 세션의 연결을 끝낸다)이 함께 쓴다.
|
||||
* 이 모듈은 다른 기능 모듈을 가져오지 않는다 (인증 → 메일 → 이 모듈 방향만 — 순환 없음).
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AuthSessionEntity])],
|
||||
providers: [AuthSessionRepository, RevokedSessions],
|
||||
exports: [AuthSessionRepository, RevokedSessions],
|
||||
})
|
||||
export class AuthSessionModule {}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, MoreThan, Not, type Repository } from 'typeorm';
|
||||
import { IsNull, MoreThan, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthSessionEntity,
|
||||
type SessionRevokedReason,
|
||||
} from './auth-session.entity';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
export interface RotateSessionCommand {
|
||||
readonly id: string;
|
||||
@@ -14,16 +15,27 @@ export interface RotateSessionCommand {
|
||||
readonly nextHash: string;
|
||||
readonly nextExpiresAt: Date;
|
||||
readonly now: Date;
|
||||
/** 이 갱신의 클라이언트 IP · User-Agent 원문 — 로그인된 브라우저 목록 (계약 5절). 모르면 null */
|
||||
readonly ip: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
/** 조건부 UPDATE … RETURNING id 의 결과 행 */
|
||||
interface ReturnedId {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 표 접근 — 판단(회전·재사용·만료)은 AuthService 와 auth-session.policy.ts 가 한다.
|
||||
* 세션을 끝내는 메서드는 **끝낸 id 를 끊은 세션 기억(RevokedSessions)에도 넣는다** — 그 세션의 액세스 토큰을 가드가 바로 막는다
|
||||
* (계약 5절 "로그인된 브라우저"). 끝내는 길을 여기 한 곳에 두어 어느 길도 기억을 빠뜨리지 않게 한다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionRepository {
|
||||
constructor(
|
||||
@InjectRepository(AuthSessionEntity)
|
||||
private readonly sessions: Repository<AuthSessionEntity>,
|
||||
private readonly revoked: RevokedSessions,
|
||||
) {}
|
||||
|
||||
async create(session: AuthSessionEntity): Promise<void> {
|
||||
@@ -34,10 +46,23 @@ export class AuthSessionRepository {
|
||||
return this.sessions.findOneBy({ id });
|
||||
}
|
||||
|
||||
/** 그 직원의 살아 있는 세션 (끊기지도 끝나지도 않은 것) — 로그인된 브라우저 목록. 차례는 부르는 쪽이 정한다 */
|
||||
listAliveOf(userEmail: string, now: Date): Promise<AuthSessionEntity[]> {
|
||||
return this.sessions.find({
|
||||
where: {
|
||||
userEmail,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(now),
|
||||
absoluteExpiresAt: MoreThan(now),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 1회용 회전 — **조건부 UPDATE 한 번**으로 한다.
|
||||
* 지금 토큰이 요청의 토큰이고 세션이 살아 있을 때만 바뀐다. 같은 토큰으로 동시에 온 두 요청 중
|
||||
* 하나만 성공한다 (PostgreSQL 은 먼저 잡은 쪽이 커밋한 뒤 나머지의 WHERE 를 다시 평가한다).
|
||||
* 갱신한 클라이언트의 IP · User-Agent 를 함께 고친다 (로그인된 브라우저 목록의 마지막 활동)
|
||||
* @returns 바꿨으면 true
|
||||
*/
|
||||
async rotate(command: RotateSessionCommand): Promise<boolean> {
|
||||
@@ -54,27 +79,49 @@ export class AuthSessionRepository {
|
||||
previousTokenHash: command.presentedHash,
|
||||
rotatedAt: command.now,
|
||||
expiresAt: command.nextExpiresAt,
|
||||
ip: command.ip,
|
||||
userAgent: command.userAgent,
|
||||
},
|
||||
);
|
||||
return result.affected === 1;
|
||||
}
|
||||
|
||||
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다 */
|
||||
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다. 끝냈으면 true */
|
||||
async revoke(
|
||||
id: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
await this.sessions.update(
|
||||
{ id, revokedAt: IsNull() },
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
): Promise<boolean> {
|
||||
const ids = await this.revokeWhere(
|
||||
'id = :id AND revoked_at IS NULL',
|
||||
{ id },
|
||||
reason,
|
||||
now,
|
||||
);
|
||||
return ids.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원의 **살아 있는** 세션 하나를 끝낸다 — 남의 것 · 없는 것 · 이미 끝난 것이면 false (로그인된 브라우저에서 끊기 — 404 RES_001)
|
||||
*/
|
||||
async revokeAliveOf(
|
||||
id: string,
|
||||
userEmail: string,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const ids = await this.revokeWhere(
|
||||
'id = :id AND user_email = :userEmail AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now',
|
||||
{ id, userEmail, now },
|
||||
reason,
|
||||
now,
|
||||
);
|
||||
return ids.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원의 살아 있는 세션을 모두 끝낸다 — `keepId` 세션만 남긴다 (없으면 모두) → 끝낸 수.
|
||||
* 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 쓴다.
|
||||
* user_email 에는 인덱스가 없다 — 표가 작고(끝난 세션은 7일 뒤 정리) 드문 일이라 순차 조회로 충분하다.
|
||||
* 이미 끝난(만료·폐기) 세션은 건드리지 않는다 (끝난 사유를 덮어쓰지 않게). 비밀번호를 바꿨을 때 · 다른 곳 모두 로그아웃에 쓴다.
|
||||
*/
|
||||
async revokeAllOf(
|
||||
userEmail: string,
|
||||
@@ -82,17 +129,13 @@ export class AuthSessionRepository {
|
||||
now: Date,
|
||||
keepId: string | null,
|
||||
): Promise<number> {
|
||||
const result = await this.sessions.update(
|
||||
{
|
||||
userEmail,
|
||||
revokedAt: IsNull(),
|
||||
expiresAt: MoreThan(now),
|
||||
absoluteExpiresAt: MoreThan(now),
|
||||
...(keepId !== null ? { id: Not(keepId) } : {}),
|
||||
},
|
||||
{ revokedAt: now, revokedReason: reason },
|
||||
const ids = await this.revokeWhere(
|
||||
`user_email = :userEmail AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now${keepId !== null ? ' AND id <> :keepId' : ''}`,
|
||||
{ userEmail, now, ...(keepId !== null ? { keepId } : {}) },
|
||||
reason,
|
||||
now,
|
||||
);
|
||||
return result.affected ?? 0;
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,4 +152,28 @@ export class AuthSessionRepository {
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/** 조건에 맞는 세션을 끝내고(UPDATE … RETURNING id) 끝낸 id 를 끊은 세션 기억에 넣는다 → 끝낸 id */
|
||||
private async revokeWhere(
|
||||
where: string,
|
||||
parameters: Record<string, unknown>,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const result = await this.sessions
|
||||
.createQueryBuilder()
|
||||
.update(AuthSessionEntity)
|
||||
.set({ revokedAt: now, revokedReason: reason })
|
||||
.where(where, parameters)
|
||||
.returning('id')
|
||||
.execute();
|
||||
const rows: unknown = result.raw;
|
||||
const ids = Array.isArray(rows)
|
||||
? (rows as ReturnedId[])
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
this.revoked.add(ids, now);
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { REFRESH_TOKEN_COOKIE } from './auth.constants';
|
||||
import { AuthSessionsService } from './auth-sessions.service';
|
||||
import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto';
|
||||
|
||||
// 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다.
|
||||
// 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션)
|
||||
@ApiTags('Auth')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('auth/sessions')
|
||||
export class AuthSessionsController {
|
||||
constructor(private readonly authSessions: AuthSessionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '로그인된 브라우저 — 끝나지 않은 웹메일 세션',
|
||||
description:
|
||||
'지금 세션(리프레시 쿠키가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' +
|
||||
'IP · 브라우저는 로그인 · 갱신 때 서버가 둔다(12단계 전에 연 세션은 null). 위치 커서 · 기본 20 · 최대 100.',
|
||||
})
|
||||
@ApiStandardResponse(AuthSessionPageDto, { description: '한 쪽' })
|
||||
@ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 목록의 커서')
|
||||
async list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: CursorPaginationQueryDto,
|
||||
@Req() req: Request,
|
||||
): Promise<AuthSessionPageDto> {
|
||||
const page = await this.authSessions.list(
|
||||
user.email,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
{ cursor: query.cursor, limit: query.limit },
|
||||
);
|
||||
return {
|
||||
items: page.items.map((session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
lastActiveAt: session.lastActiveAt.toISOString(),
|
||||
ip: session.ip,
|
||||
device: session.device,
|
||||
isCurrent: session.isCurrent,
|
||||
})),
|
||||
pageInfo: page.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그인된 브라우저 하나 끊기',
|
||||
description:
|
||||
'그 세션을 끊는다 — 그 브라우저는 다음 요청에서 갱신이 실패해 로그인 화면으로 간다(열린 실시간 알림은 다음 ping 때 끝난다). 지금 세션은 로그아웃을 쓴다.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '세션 id' })
|
||||
@ApiStandardResponse(undefined, { description: '끊었다 (data: null)' })
|
||||
@ApiErrorResponse('VAL_001', '지금 세션 — 로그아웃을 쓴다')
|
||||
@ApiErrorResponse('RES_001', '남의 것 · 없는 것 · 이미 끝난 것')
|
||||
async end(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
): Promise<null> {
|
||||
await this.authSessions.end(
|
||||
user.email,
|
||||
id,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '다른 곳 모두 로그아웃 — 이 요청의 세션만 남긴다',
|
||||
description:
|
||||
'리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.',
|
||||
})
|
||||
@ApiStandardResponse(EndedSessionsDto, { description: '끊은 세션 수' })
|
||||
async endOthers(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() req: Request,
|
||||
): Promise<EndedSessionsDto> {
|
||||
const ended = await this.authSessions.endOthers(
|
||||
user.email,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
return { ended };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { encodeOffsetCursor } from '@/shared/utils/offset-cursor';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import type { AuthSessionRepository } from './auth-session.repository';
|
||||
import { AuthSessionsService } from './auth-sessions.service';
|
||||
import type { AuthService } from './auth.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const CURRENT = '11111111-1111-4111-8111-111111111111';
|
||||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||||
const OLDER = '33333333-3333-4333-8333-333333333333';
|
||||
const CHROME =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
|
||||
|
||||
const session = (
|
||||
id: string,
|
||||
lastActive: string,
|
||||
extra: Partial<AuthSessionEntity> = {},
|
||||
): AuthSessionEntity => ({
|
||||
id,
|
||||
userEmail: ARA,
|
||||
tokenHash: 'h',
|
||||
previousTokenHash: null,
|
||||
rotatedAt: new Date(lastActive),
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
expiresAt: new Date('2026-11-10T00:00:00.000Z'),
|
||||
absoluteExpiresAt: new Date('2026-11-29T00:00:00.000Z'),
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: CHROME,
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () => {
|
||||
let auth: { currentSessionId: jest.Mock };
|
||||
let sessions: {
|
||||
listAliveOf: jest.Mock;
|
||||
revokeAliveOf: jest.Mock;
|
||||
revokeAllOf: jest.Mock;
|
||||
};
|
||||
let service: AuthSessionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
auth = { currentSessionId: jest.fn().mockResolvedValue(CURRENT) };
|
||||
sessions = {
|
||||
listAliveOf: jest.fn().mockResolvedValue([
|
||||
session(OLDER, '2026-10-30T01:00:00.000Z', {
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
}),
|
||||
session(OTHER, '2026-10-31T02:00:00.000Z'),
|
||||
session(CURRENT, '2026-10-30T05:00:00.000Z'),
|
||||
]),
|
||||
revokeAliveOf: jest.fn().mockResolvedValue(true),
|
||||
revokeAllOf: jest.fn().mockResolvedValue(2),
|
||||
};
|
||||
service = new AuthSessionsService(
|
||||
auth as unknown as AuthService,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
);
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('지금 세션이 맨 앞, 그다음 최근 활동 차례 — 브라우저 · 운영체제는 짧은 이름, 12단계 전의 세션은 null', async () => {
|
||||
const page = await service.list(ARA, 'refresh.cookie', { limit: 20 });
|
||||
|
||||
expect(auth.currentSessionId).toHaveBeenCalledWith('refresh.cookie', ARA);
|
||||
expect(page.items.map(({ id, isCurrent }) => [id, isCurrent])).toEqual([
|
||||
[CURRENT, true],
|
||||
[OTHER, false],
|
||||
[OLDER, false],
|
||||
]);
|
||||
expect(page.items[1]).toMatchObject({
|
||||
lastActiveAt: new Date('2026-10-31T02:00:00.000Z'),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
});
|
||||
expect(page.items[2]).toMatchObject({ ip: null, device: null });
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('위치 커서로 나눠 준다 — 다른 목록의 커서는 VAL_001', async () => {
|
||||
const first = await service.list(ARA, undefined, { limit: 2 });
|
||||
expect(first.pageInfo.hasNext).toBe(true);
|
||||
const second = await service.list(ARA, undefined, {
|
||||
limit: 2,
|
||||
cursor: first.pageInfo.nextCursor ?? undefined,
|
||||
});
|
||||
expect(second.items).toHaveLength(1);
|
||||
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.list(ARA, undefined, {
|
||||
limit: 2,
|
||||
cursor: encodeOffsetCursor(1, 'contacts'),
|
||||
}),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('리프레시 쿠키가 없거나 남의 것이면 isCurrent 는 모두 false', async () => {
|
||||
auth.currentSessionId.mockResolvedValue(null);
|
||||
const page = await service.list(ARA, undefined, { limit: 20 });
|
||||
expect(page.items.every((item) => !item.isCurrent)).toBe(true);
|
||||
});
|
||||
|
||||
it('하나 끊기 — 지금 세션은 400 VAL_001, 모양이 아닌 id · 남의 것 · 없는 것 · 끝난 것은 404 RES_001', async () => {
|
||||
await expect(service.end(ARA, OTHER, 'cookie')).resolves.toBeUndefined();
|
||||
expect(sessions.revokeAliveOf).toHaveBeenCalledWith(
|
||||
OTHER,
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
expect.any(Date),
|
||||
);
|
||||
|
||||
expect((await failureOf(service.end(ARA, CURRENT, 'cookie')))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(
|
||||
(await failureOf(service.end(ARA, 'not-a-uuid', 'cookie')))?.code,
|
||||
).toBe('RES_001');
|
||||
sessions.revokeAliveOf.mockResolvedValue(false);
|
||||
expect((await failureOf(service.end(ARA, OLDER, 'cookie')))?.code).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 곳 모두 끊기 — 지금 세션만 남긴다, 지금 세션을 모르면 모두', async () => {
|
||||
await expect(service.endOthers(ARA, 'cookie')).resolves.toBe(2);
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
expect.any(Date),
|
||||
CURRENT,
|
||||
);
|
||||
|
||||
auth.currentSessionId.mockResolvedValue(null);
|
||||
await service.endOthers(ARA, undefined);
|
||||
expect(sessions.revokeAllOf).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import { describeUserAgent, type DeviceNames } from '@/shared/utils/user-agent';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { AuthService } from './auth.service';
|
||||
import { normalizeEmail } from './employee-address';
|
||||
|
||||
/** 커서의 범위 — 이 목록의 커서만 받는다 */
|
||||
const SESSION_LIST_SCOPE = 'auth-sessions';
|
||||
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 없는 세션과 같다) */
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */
|
||||
export interface AuthSessionView {
|
||||
readonly id: string;
|
||||
readonly createdAt: Date;
|
||||
/** 마지막 갱신(회전) 때 — 없으면 로그인 때 */
|
||||
readonly lastActiveAt: Date;
|
||||
readonly ip: string | null;
|
||||
readonly device: DeviceNames | null;
|
||||
/** 이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 */
|
||||
readonly isCurrent: boolean;
|
||||
}
|
||||
|
||||
function lastActiveAt(session: AuthSessionEntity): Date {
|
||||
return session.rotatedAt ?? session.createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 세션의 목록 · 끊기.
|
||||
*
|
||||
* - 지금 세션은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 안다 — 없거나 남의 것이면 isCurrent 는 모두 false,
|
||||
* "다른 곳 모두 끊기"는 모두 끊는다(비밀번호 바꾸기와 같다)
|
||||
* - 끊은 세션은 바로 막힌다 — 세션 표의 끊기가 끊은 세션 기억에 넣고 가드가 그 액세스 토큰을 막는다 (auth-session.repository.ts)
|
||||
* - 목록은 지금 세션이 맨 앞, 그다음 최근 활동 차례 — 위치 커서(shared/utils/offset-cursor.ts), 기본 20 · 최대 100
|
||||
* 끊기는 info 로그에 가린 주소와 세션 id · 수만
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthSessionsService {
|
||||
private readonly logger = new Logger(AuthSessionsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
) {}
|
||||
|
||||
/** GET /api/auth/sessions */
|
||||
async list(
|
||||
user: string,
|
||||
refreshToken: string | undefined,
|
||||
query: { readonly cursor?: string; readonly limit: number },
|
||||
now: Date = new Date(),
|
||||
): Promise<PaginatedResult<AuthSessionView>> {
|
||||
const address = normalizeEmail(user);
|
||||
const start = this.offsetOf(query.cursor);
|
||||
const [currentId, rows] = await Promise.all([
|
||||
this.auth.currentSessionId(refreshToken, address),
|
||||
this.sessions.listAliveOf(address, now),
|
||||
]);
|
||||
|
||||
const sorted = rows.sort((a, b) => {
|
||||
if (a.id === currentId) return -1;
|
||||
if (b.id === currentId) return 1;
|
||||
return (
|
||||
lastActiveAt(b).getTime() - lastActiveAt(a).getTime() ||
|
||||
a.id.localeCompare(b.id)
|
||||
);
|
||||
});
|
||||
const page = sorted.slice(start, start + query.limit);
|
||||
const next = start + page.length;
|
||||
const hasNext = next < sorted.length;
|
||||
return toPaginatedResult(
|
||||
page.map((session) => ({
|
||||
id: session.id,
|
||||
createdAt: session.createdAt,
|
||||
lastActiveAt: lastActiveAt(session),
|
||||
ip: session.ip,
|
||||
device: describeUserAgent(session.userAgent),
|
||||
isCurrent: session.id === currentId,
|
||||
})),
|
||||
{
|
||||
hasNext,
|
||||
nextCursor: hasNext
|
||||
? encodeOffsetCursor(next, SESSION_LIST_SCOPE)
|
||||
: null,
|
||||
total: sorted.length,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/sessions/{id} — 지금 세션은 400 VAL_001 (로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001
|
||||
*/
|
||||
async end(
|
||||
user: string,
|
||||
sessionId: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(user);
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) throw this.sessionNotFound();
|
||||
const currentId = await this.auth.currentSessionId(refreshToken, address);
|
||||
if (sessionId.toLowerCase() === currentId?.toLowerCase()) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'cannot-end-current-session', sid: sessionId },
|
||||
});
|
||||
}
|
||||
const ended = await this.sessions.revokeAliveOf(
|
||||
sessionId,
|
||||
address,
|
||||
'ended-by-user',
|
||||
new Date(),
|
||||
);
|
||||
if (!ended) throw this.sessionNotFound();
|
||||
this.logger.log(
|
||||
`로그인된 브라우저를 끊었습니다: ${maskEmail(address)}, sid=${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 */
|
||||
async endOthers(
|
||||
user: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<number> {
|
||||
const address = normalizeEmail(user);
|
||||
const currentId = await this.auth.currentSessionId(refreshToken, address);
|
||||
const ended = await this.sessions.revokeAllOf(
|
||||
address,
|
||||
'ended-by-user',
|
||||
new Date(),
|
||||
currentId,
|
||||
);
|
||||
this.logger.log(
|
||||
`다른 곳의 로그인을 끊었습니다: ${maskEmail(address)}, ${ended}곳${currentId === null ? ' (지금 세션을 알 수 없어 모두)' : ''}`,
|
||||
);
|
||||
return ended;
|
||||
}
|
||||
|
||||
private offsetOf(cursor: string | undefined): number {
|
||||
if (cursor === undefined) return 0;
|
||||
const reading = decodeOffsetCursor(cursor, SESSION_LIST_SCOPE);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-session-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
return reading.offset;
|
||||
}
|
||||
|
||||
private sessionNotFound(): BusinessException {
|
||||
return new BusinessException('RES_001', {
|
||||
context: { reason: 'session-not-found' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ export interface AuthTokenPayload {
|
||||
readonly typ: AuthTokenType;
|
||||
/** 표시 이름 — 액세스 토큰에만 넣는다 (/auth/me 가 메일 엔진에 묻지 않고 답한다) */
|
||||
readonly name?: string;
|
||||
/** 세션 id — 리프레시 토큰에만 넣는다 (auth_sessions.id). 회전·폐기를 이 세션 기준으로 판단한다 */
|
||||
/**
|
||||
* 세션 id (auth_sessions.id) — 리프레시 토큰은 회전 · 폐기를 이 세션 기준으로 판단한다.
|
||||
* 액세스 토큰에도 싣는다(12단계) — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (revoked-sessions.ts).
|
||||
* 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다
|
||||
*/
|
||||
readonly sid?: string;
|
||||
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
|
||||
readonly jti?: string;
|
||||
|
||||
@@ -60,6 +60,23 @@ export const LOGIN_IP_FAILURE_WINDOW_MS = 60_000;
|
||||
export const LOGIN_ADDRESS_FAILURE_LIMIT = 10;
|
||||
export const LOGIN_ADDRESS_FAILURE_WINDOW_MS = 15 * 60_000;
|
||||
|
||||
/**
|
||||
* 2단계 인증 **코드** 실패 한도 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 센다.
|
||||
* 같은 주소 15분 5회 · 24시간 20회 — 비밀번호를 아는 공격자가 1년 내내 시도해도 맞힐 확률이 약 2% (창이 앞뒤 한 단계라 한 번에 3/10⁶).
|
||||
* 같은 IP 는 따로 세지 않고 로그인 실패 한도(LOGIN_IP_FAILURE_LIMIT — 1분 20회)에 함께 센다.
|
||||
* 맞으면 차지한 자리만 돌려주고 그 주소의 기록은 지우지 않는다 — 직원이 자주 들어와도 공격자의 하루 20회가 늘지 않게
|
||||
*/
|
||||
export const TWO_FACTOR_CODE_ADDRESS_LIMIT = 5;
|
||||
export const TWO_FACTOR_CODE_ADDRESS_WINDOW_MS = 15 * 60_000;
|
||||
export const TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT = 20;
|
||||
export const TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS = DAY_SECONDS * 1000;
|
||||
|
||||
/**
|
||||
* 끊은 세션 id 를 기억하는 시간 (계약 5절 "로그인된 브라우저") — 액세스 토큰의 가장 긴 수명(env 검증의 상한)과 같다.
|
||||
* 액세스 토큰은 세션을 끊은 뒤에는 새로 발급되지 않으므로, 이 시간이 지나면 그 세션의 액세스 토큰은 모두 만료됐다
|
||||
*/
|
||||
export const REVOKED_SESSION_MEMORY_MS = ACCESS_TTL_MAX_SECONDS * 1000;
|
||||
|
||||
/**
|
||||
* 로그인 요청 수 상한 (IP 당 1분) — 실패 한도와 별개로 요청 자체를 넉넉하게 제한한다.
|
||||
* 실패 한도가 무차별 대입을 막고, 이 상한은 한 IP 가 엔진 확인을 폭주시키지 못하게 한다.
|
||||
|
||||
@@ -15,6 +15,7 @@ const settings: AuthSettings = {
|
||||
};
|
||||
|
||||
const session = {
|
||||
status: 'signedIn',
|
||||
user: { email: 'ara.kim@arasystem.test', name: '김아라' },
|
||||
tokens: {
|
||||
accessToken: 'new.access',
|
||||
@@ -27,6 +28,21 @@ function fakeResponse() {
|
||||
return { cookie: jest.fn(), clearCookie: jest.fn() };
|
||||
}
|
||||
|
||||
/** 요청 대역 — 쿠키와 User-Agent 머리글 */
|
||||
function fakeRequest(
|
||||
cookies: Record<string, string>,
|
||||
userAgent: string | undefined = undefined,
|
||||
) {
|
||||
return {
|
||||
cookies,
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'user-agent' ? userAgent : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const CHROME =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
|
||||
|
||||
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드·한도)를 이 함수에 붙인다 */
|
||||
function handlerOf(
|
||||
name: 'login' | 'me' | 'refresh' | 'logout' | 'changePassword',
|
||||
@@ -85,13 +101,14 @@ describe('AuthController', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인은 클라이언트 IP 를 서비스에 넘기고 두 쿠키를 굽는다', async () => {
|
||||
it('로그인은 클라이언트 IP · User-Agent · 코드를 서비스에 넘기고, signedIn 이면 두 쿠키를 굽는다 (계약 5절 — status 가 붙는다)', async () => {
|
||||
auth.login.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.login(
|
||||
{ email: 'ara.kim@arasystem.test', password: 'pw' },
|
||||
const result = await controller.login(
|
||||
{ email: 'ara.kim@arasystem.test', password: 'pw', otpCode: '123456' },
|
||||
'203.0.113.10',
|
||||
fakeRequest({}, CHROME) as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
@@ -99,22 +116,56 @@ describe('AuthController', () => {
|
||||
'ara.kim@arasystem.test',
|
||||
'pw',
|
||||
'203.0.113.10',
|
||||
{ otpCode: '123456', userAgent: CHROME },
|
||||
);
|
||||
expect(user).toEqual(session.user);
|
||||
expect(result).toEqual({ status: 'signedIn', ...session.user });
|
||||
expect(res.cookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('리프레시는 리프레시 쿠키만 읽어 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
|
||||
auth.refresh.mockResolvedValue(session);
|
||||
it('2단계 인증이 필요하면 쿠키 없이 { status: twoFactorRequired, email: null, name: null }', async () => {
|
||||
auth.login.mockResolvedValue({
|
||||
status: 'twoFactorRequired',
|
||||
user: null,
|
||||
tokens: null,
|
||||
});
|
||||
const res = fakeResponse();
|
||||
const req = { cookies: { refresh_token: 'old.refresh' } };
|
||||
|
||||
const user = await controller.refresh(
|
||||
req as unknown as Request,
|
||||
const result = await controller.login(
|
||||
{ email: 'ara.kim@arasystem.test', password: 'pw' },
|
||||
'203.0.113.10',
|
||||
fakeRequest({}) as unknown as Request,
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.refresh).toHaveBeenCalledWith('old.refresh');
|
||||
expect(auth.login).toHaveBeenCalledWith(
|
||||
'ara.kim@arasystem.test',
|
||||
'pw',
|
||||
'203.0.113.10',
|
||||
{ otpCode: undefined, userAgent: null },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
email: null,
|
||||
name: null,
|
||||
});
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('리프레시는 리프레시 쿠키와 클라이언트(IP · User-Agent — 로그인된 브라우저 목록)를 서비스에 넘기고 두 쿠키를 새로 굽는다', async () => {
|
||||
auth.refresh.mockResolvedValue(session);
|
||||
const res = fakeResponse();
|
||||
const req = fakeRequest({ refresh_token: 'old.refresh' }, CHROME);
|
||||
|
||||
const user = await controller.refresh(
|
||||
req as unknown as Request,
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(auth.refresh).toHaveBeenCalledWith('old.refresh', {
|
||||
ip: '203.0.113.10',
|
||||
userAgent: CHROME,
|
||||
});
|
||||
expect(user).toEqual(session.user);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
@@ -133,7 +184,8 @@ describe('AuthController', () => {
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
fakeRequest({ refresh_token: 'old.refresh' }) as unknown as Request,
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
@@ -148,7 +200,8 @@ describe('AuthController', () => {
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: {} } as unknown as Request,
|
||||
fakeRequest({}) as unknown as Request,
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
@@ -168,7 +221,8 @@ describe('AuthController', () => {
|
||||
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ cookies: { refresh_token: 'old.refresh' } } as unknown as Request,
|
||||
fakeRequest({ refresh_token: 'old.refresh' }) as unknown as Request,
|
||||
'203.0.113.10',
|
||||
res as unknown as Response,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BusinessException);
|
||||
@@ -193,7 +247,11 @@ describe('AuthController', () => {
|
||||
await expect(
|
||||
controller.changePassword(
|
||||
user,
|
||||
{ currentPassword: 'old-password', newPassword: 'new-password-1' },
|
||||
{
|
||||
currentPassword: 'old-password',
|
||||
newPassword: 'new-password-1',
|
||||
otpCode: '654321',
|
||||
},
|
||||
'203.0.113.10',
|
||||
req as unknown as Request,
|
||||
),
|
||||
@@ -204,6 +262,7 @@ describe('AuthController', () => {
|
||||
'new-password-1',
|
||||
'203.0.113.10',
|
||||
'this.refresh',
|
||||
'654321',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthUserDto } from './dto/auth-user.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { LoginResultDto } from './dto/login-result.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
@@ -60,26 +61,44 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
|
||||
description:
|
||||
'2단계 인증을 켠 직원은 두 단계다 (계약 5절): 비밀번호가 맞으면 200 { status: twoFactorRequired, email: null, name: null } — 쿠키 없음. ' +
|
||||
'같은 비밀번호에 otpCode 를 더해 다시 보내면 200 { status: signedIn, … } + 쿠키. 2단계 인증을 켜지 않은 직원은 바로 signedIn 이다(코드가 와도 보지 않는다). ' +
|
||||
'앱 비밀번호는 받지 않는다(AUTH_004). 코드 실패는 같은 주소 15분 5회 · 24시간 20회, 같은 코드는 2분 동안 다시 받지 않는다.',
|
||||
})
|
||||
@ApiStandardResponse(LoginResultDto, {
|
||||
description:
|
||||
'로그인 성공(signedIn) 또는 2단계 인증 코드 필요(twoFactorRequired)',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 성공' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_004',
|
||||
'계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
|
||||
'계정 없음·비밀번호 틀림(코드 단계에서도)·앱 비밀번호·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다)',
|
||||
)
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 형식 오류(otpCode 가 숫자 6자리가 아님 등) (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '형식 오류')
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'시도 과다 — 같은 IP 1분에 실패 20회 · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
|
||||
'시도 과다 — 같은 IP 1분에 실패 20회(코드 실패 포함) · 같은 주소 15분에 실패 10회(성공은 세지 않는다) · 코드 실패 같은 주소 15분 5회 · 24시간 20회 · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Ip() ip: string,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
const result = await this.auth.login(dto.email, dto.password, ip);
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
): Promise<LoginResultDto> {
|
||||
const result = await this.auth.login(dto.email, dto.password, ip, {
|
||||
otpCode: dto.otpCode,
|
||||
userAgent: req.get('user-agent') ?? null,
|
||||
});
|
||||
if (result.status === 'twoFactorRequired') {
|
||||
return { status: result.status, email: null, name: null };
|
||||
}
|
||||
setSessionCookies(res, result.tokens, this.settings);
|
||||
return { status: result.status, ...result.user };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@@ -116,11 +135,13 @@ export class AuthController {
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 쿠키는 그대로 둔다')
|
||||
async refresh(
|
||||
@Req() req: Request,
|
||||
@Ip() ip: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AuthUserDto> {
|
||||
try {
|
||||
const result = await this.auth.refresh(
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
{ ip, userAgent: req.get('user-agent') ?? null },
|
||||
);
|
||||
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
@@ -149,7 +170,9 @@ export class AuthController {
|
||||
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
|
||||
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
|
||||
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다.',
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' +
|
||||
'12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' +
|
||||
'바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다.',
|
||||
})
|
||||
@ApiStandardResponse(undefined, {
|
||||
description: '바꿨다 (data: null)',
|
||||
@@ -157,13 +180,14 @@ export class AuthController {
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_005',
|
||||
'AUTH_005 지금 비밀번호가 틀림 · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' +
|
||||
'AUTH_005 지금 비밀번호가 틀림 · 앱 비밀번호(세지 않는다) · 지금 비밀번호로 들어갔지만 메일 엔진이 바꾸기를 거절함(변경 권한 없음 등 — 이것은 실패로 세지 않는다) — 세션은 그대로다. ' +
|
||||
'401 이 아니다(클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다) · ' +
|
||||
'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드(코드 실패로 센다) · AUTH_007 2단계 인증을 켠 직원인데 코드가 없음(다른 탭에서 켠 경우 — 화면이 코드 칸을 보인다) · ' +
|
||||
'VAL_001 새 비밀번호가 8자 미만 · 128자 초과(JS 문자열 길이) · 지금 비밀번호와 같음 · 메일 엔진 규칙에 어긋남, 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 같은 주소의 로그인·변경이 처리 중',
|
||||
'지금 비밀번호 틀림이 잦음(로그인 실패와 같은 한도) · 코드 실패 한도 · 같은 주소의 로그인·변경이 처리 중 · 같은 직원의 2단계 인증 바꾸기가 처리 중',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
@@ -171,7 +195,7 @@ export class AuthController {
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 두었다)',
|
||||
'메일 엔진 장애 (바뀌지 않았다) · 결과를 알 수 없게 끝남(시간 초과 · 연결 끊김 · 엔진 5xx — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊고 앱 비밀번호는 지워 두었다)',
|
||||
)
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@@ -185,6 +209,7 @@ export class AuthController {
|
||||
dto.newPassword,
|
||||
ip,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
dto.otpCode,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -193,6 +218,8 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그아웃 — 항상 200, 서버의 세션을 끊고 두 쿠키를 지운다',
|
||||
description:
|
||||
'끊은 세션의 액세스 토큰은 서버가 1시간 기억해 바로 막는다(12단계 — 계약 5절 "로그인된 브라우저").',
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
|
||||
async logout(
|
||||
|
||||
@@ -2,18 +2,19 @@ import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { AuthSessionModule } from './auth-session.module';
|
||||
import { AuthSessionsController } from './auth-sessions.controller';
|
||||
import { AuthSessionsService } from './auth-sessions.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, buildAuthSettings } from './auth.settings';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
import { LoginAttemptModule } from './login-attempt.module';
|
||||
|
||||
/**
|
||||
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
|
||||
@@ -23,17 +24,22 @@ import { LoginAttemptService } from './login-attempt.service';
|
||||
* 보호 라우트는 `@UseGuards(JwtAuthGuard)` (common/guards) 로 건다.
|
||||
*
|
||||
* 로그인 세션은 DB(auth_sessions)에 둔다 — 스키마는 마이그레이션으로만 바꾼다 (src/migrations).
|
||||
* 세션 표와 끊은 세션 기억은 AuthSessionModule 이(메일 모듈의 실시간 알림과 함께 쓴다), 실패 한도는 LoginAttemptModule 이(계정 보안 모듈과 함께 쓴다) 내놓는다.
|
||||
* 12단계 — 2단계 인증 코드 · 로그인 기록 · 앱 비밀번호는 AccountSecurityModule 을 쓴다 (인증 → 계정 보안 방향만, 순환 없음)
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ session: false }),
|
||||
JwtModule.register({}),
|
||||
TypeOrmModule.forFeature([AuthSessionEntity]),
|
||||
AuthSessionModule,
|
||||
LoginAttemptModule,
|
||||
MailModule,
|
||||
// 로그인에 성공한 직원을 사내 목록에 기록한다 (받는 사람 추천)
|
||||
MailDirectoryModule,
|
||||
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
|
||||
AccountSecurityModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
controllers: [AuthController, AuthSessionsController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH_SETTINGS,
|
||||
@@ -45,9 +51,8 @@ import { LoginAttemptService } from './login-attempt.service';
|
||||
),
|
||||
},
|
||||
AuthService,
|
||||
AuthSessionRepository,
|
||||
AuthSessionsService,
|
||||
AuthSessionCleanup,
|
||||
LoginAttemptService,
|
||||
JwtStrategy,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AppPasswordsService } from '@/modules/account-security/app-passwords.service';
|
||||
import type { LoginEventsService } from '@/modules/account-security/login-events.service';
|
||||
import { TwoFactorCodeGuard } from '@/modules/account-security/two-factor-code-guard';
|
||||
import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks';
|
||||
import type { MailProvider } from '@/modules/mail/mail.provider';
|
||||
import type { CredentialCheck } from '@/modules/mail/mail.types';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
@@ -23,6 +28,15 @@ import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const PASSWORD = 'correct-horse-battery';
|
||||
|
||||
/** 엔진의 자격 확인 결과 (CredentialCheck) */
|
||||
const valid = (account: string): CredentialCheck => ({
|
||||
kind: 'valid',
|
||||
account,
|
||||
});
|
||||
const INVALID: CredentialCheck = { kind: 'invalid' };
|
||||
/** 비밀번호는 맞고 2단계 인증 코드가 필요하다 (엔진 402) */
|
||||
const TWO_FACTOR: CredentialCheck = { kind: 'two-factor-required' };
|
||||
const CLIENT_IP = '203.0.113.10';
|
||||
const DAY_SECONDS = 24 * 3600;
|
||||
|
||||
@@ -84,6 +98,8 @@ class FakeSessions {
|
||||
previousTokenHash: command.presentedHash,
|
||||
rotatedAt: command.now,
|
||||
expiresAt: command.nextExpiresAt,
|
||||
ip: command.ip,
|
||||
userAgent: command.userAgent,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(alive);
|
||||
@@ -161,6 +177,15 @@ function countCodes(results: PromiseSettledResult<unknown>[]) {
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** 손으로 푸는 약속 — 엔진 호출 · DB 저장이 걸려 있는 동안 다른 요청을 보낸다 */
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** 조건이 될 때까지 짧게 다시 본다 — 고정 대기 대신, 기한은 넉넉하게 */
|
||||
async function until(
|
||||
condition: () => boolean,
|
||||
@@ -175,11 +200,21 @@ async function until(
|
||||
|
||||
describe('AuthService', () => {
|
||||
let mail: jest.Mocked<
|
||||
Pick<MailProvider, 'verifyCredentials' | 'getProfile' | 'changePassword'>
|
||||
Pick<
|
||||
MailProvider,
|
||||
| 'verifyCredentials'
|
||||
| 'verifyTwoFactorLogin'
|
||||
| 'getProfile'
|
||||
| 'changePassword'
|
||||
>
|
||||
>;
|
||||
let attempts: LoginAttemptService;
|
||||
let sessions: FakeSessions;
|
||||
let directory: { recordLogin: jest.Mock };
|
||||
let codes: TwoFactorCodeGuard;
|
||||
let twoFactorLocks: TwoFactorLocks;
|
||||
let loginEvents: { recordWebmail: jest.Mock };
|
||||
let appPasswords: { removeAllAfterPasswordChange: jest.Mock };
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -193,6 +228,10 @@ describe('AuthService', () => {
|
||||
attempts,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
directory as unknown as MailDirectoryService,
|
||||
codes,
|
||||
twoFactorLocks,
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -221,7 +260,8 @@ describe('AuthService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mail = {
|
||||
verifyCredentials: jest.fn().mockResolvedValue(ARA),
|
||||
verifyCredentials: jest.fn().mockResolvedValue(valid(ARA)),
|
||||
verifyTwoFactorLogin: jest.fn().mockResolvedValue('authenticated'),
|
||||
getProfile: jest
|
||||
.fn()
|
||||
.mockImplementation((email: string) =>
|
||||
@@ -232,6 +272,12 @@ describe('AuthService', () => {
|
||||
attempts = new LoginAttemptService();
|
||||
sessions = new FakeSessions();
|
||||
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
codes = new TwoFactorCodeGuard(attempts);
|
||||
twoFactorLocks = new TwoFactorLocks();
|
||||
loginEvents = { recordWebmail: jest.fn().mockResolvedValue(undefined) };
|
||||
appPasswords = {
|
||||
removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -291,7 +337,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
directory.recordLogin.mockClear();
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
@@ -303,13 +349,13 @@ describe('AuthService', () => {
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
expect(result.user.email).toBe(ARA);
|
||||
expect(result.user?.email).toBe(ARA);
|
||||
expect(logs.some((line) => line.includes('사내 목록'))).toBe(true);
|
||||
expect(logs.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('비밀번호가 틀리면 AUTH_004 이고 세션을 만들지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
|
||||
const error = await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
|
||||
|
||||
@@ -357,7 +403,7 @@ describe('AuthService', () => {
|
||||
service = build({
|
||||
blockedAccounts: new Set(['mail-admin@arasystem.test']),
|
||||
});
|
||||
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
|
||||
mail.verifyCredentials.mockResolvedValue(valid('admin@arasystem.test'));
|
||||
|
||||
const result = await service.login(
|
||||
'admin@arasystem.test',
|
||||
@@ -365,7 +411,7 @@ describe('AuthService', () => {
|
||||
CLIENT_IP,
|
||||
);
|
||||
|
||||
expect(result.user.email).toBe('admin@arasystem.test');
|
||||
expect(result.user?.email).toBe('admin@arasystem.test');
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
@@ -395,7 +441,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('별칭으로 들어와도 엔진이 알려 준 정식 계정이 관리자면 AUTH_004 — 메일함을 열지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue('admin@arasystem.test');
|
||||
mail.verifyCredentials.mockResolvedValue(valid('admin@arasystem.test'));
|
||||
|
||||
const error = await failureOf(
|
||||
service.login('postmaster@arasystem.test', PASSWORD, CLIENT_IP),
|
||||
@@ -426,7 +472,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('로그에는 마스킹한 주소만 남고 비밀번호·토큰은 남지 않는다', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
const failure = await failureOf(service.login(ARA, PASSWORD, CLIENT_IP));
|
||||
|
||||
const everything = [...logs, JSON.stringify(failure?.context)].join('\n');
|
||||
@@ -447,7 +493,7 @@ describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
mail.verifyCredentials.mockImplementation(async () => {
|
||||
await delay(80);
|
||||
return null;
|
||||
return INVALID;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,7 +521,7 @@ describe('AuthService', () => {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
);
|
||||
|
||||
mail.verifyCredentials.mockResolvedValue(ARA);
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.1')))?.code,
|
||||
).toBe('REQ_001');
|
||||
@@ -513,7 +559,7 @@ describe('AuthService', () => {
|
||||
|
||||
it('성공한 로그인은 세지 않는다 — 같은 IP(사무실 NAT)에서 한도보다 많이 성공해도 막히지 않는다', async () => {
|
||||
mail.verifyCredentials.mockImplementation((email: string) =>
|
||||
Promise.resolve(email),
|
||||
Promise.resolve(valid(email)),
|
||||
);
|
||||
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT * 2; i += 1) {
|
||||
@@ -613,7 +659,7 @@ describe('AuthService', () => {
|
||||
const result = await service.refresh(first);
|
||||
|
||||
expect(result.tokens).toBeNull();
|
||||
expect(result.user.email).toBe(ARA);
|
||||
expect(result.user?.email).toBe(ARA);
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
// 이긴 쪽의 토큰은 그대로 쓸 수 있다
|
||||
await expect(rotateFrom(next)).resolves.toEqual(expect.any(String));
|
||||
@@ -784,7 +830,7 @@ describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
// 로그인한 주소 그대로 엔진이 확인해 준다 (다른 직원의 세션도 만들 수 있게)
|
||||
mail.verifyCredentials.mockImplementation((email: string) =>
|
||||
Promise.resolve(email),
|
||||
Promise.resolve(valid(email)),
|
||||
);
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
});
|
||||
@@ -866,7 +912,7 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('로그인 실패와 한 카운터다 — 로그인 실패가 쌓인 주소는 비밀번호 바꾸기도 그만큼 덜 남는다 · IP 한도도 같다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(null);
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT - 1; i += 1) {
|
||||
await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`));
|
||||
}
|
||||
@@ -1126,6 +1172,533 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 12단계 — 2단계 인증 로그인 · 비밀번호 바꾸기 · 로그인 기록 · 세션의 sid · IP · User-Agent (계약 5절).
|
||||
* 엔진 쪽 판단(402 · 엔진 로그인 요청)은 대역으로 두고(jmap.client.spec.ts 가 본다), 한도 · 기억은 실제 LoginAttemptService · TwoFactorCodeGuard 로 본다.
|
||||
*/
|
||||
describe('12단계 — 2단계 인증 · 로그인 기록 · 세션', () => {
|
||||
const CODE = '123456';
|
||||
const CHROME =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
|
||||
/** 엔진의 앱 비밀번호 모양 — app_ + 36자 */
|
||||
const APP_PASSWORD = `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`;
|
||||
|
||||
beforeEach(() => {
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
});
|
||||
|
||||
it('2단계 인증을 켠 직원은 비밀번호가 맞으면 twoFactorRequired — 쿠키(세션) 없이, 비밀번호 실패로 세지 않는다', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
user: null,
|
||||
tokens: null,
|
||||
});
|
||||
expect(sessions.create).not.toHaveBeenCalled();
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
// 남기는 것은 판정이 끝난 시도뿐이다 (성공 · AUTH_004 · AUTH_006)
|
||||
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
|
||||
// 차지한 실패를 돌려줬다 — 주소 한도보다 많이 해도 막히지 않는다
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, `198.51.100.${i}`),
|
||||
).resolves.toMatchObject({ status: 'twoFactorRequired' });
|
||||
}
|
||||
});
|
||||
|
||||
it('코드가 맞으면 엔진의 로그인 요청(비밀번호 + 코드)으로 확인하고 로그인한다 — 로그인 기록에 성공 · 2단계 인증', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP, {
|
||||
otpCode: CODE,
|
||||
userAgent: CHROME,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('signedIn');
|
||||
expect(result.user).toEqual({ email: ARA, name: '김아라' });
|
||||
expect(mail.verifyTwoFactorLogin).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
CODE,
|
||||
);
|
||||
expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true });
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith({
|
||||
email: ARA,
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
ip: CLIENT_IP,
|
||||
userAgent: CHROME,
|
||||
});
|
||||
// 세션에 IP · User-Agent 를 둔다 (로그인된 브라우저 목록)
|
||||
expect(sessions.only()).toMatchObject({
|
||||
ip: CLIENT_IP,
|
||||
userAgent: CHROME,
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 직원이 성공에 쓴 코드는 2분 안에 다시 받지 않는다 — 400 AUTH_006 이고 엔진에 묻지 않는다 (엔진은 다시 받는다)', async () => {
|
||||
await service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE });
|
||||
mail.verifyTwoFactorLogin.mockClear();
|
||||
|
||||
const failure = await failureOf(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
);
|
||||
|
||||
expect(failure?.code).toBe('AUTH_006');
|
||||
expect(failure?.getStatus()).toBe(400);
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongCode',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('엔진이 코드를 받지 않으면 400 AUTH_006 — 코드 실패는 같은 주소 15분 5회, 넘으면 맞는 코드도 REQ_001', async () => {
|
||||
mail.verifyTwoFactorLogin.mockResolvedValue('code-rejected');
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
// IP 를 나눠 IP 한도(1분 20회)와 섞이지 않게 한다
|
||||
const failure = await failureOf(
|
||||
service.login(ARA, PASSWORD, `198.51.100.${i}`, {
|
||||
otpCode: '000000',
|
||||
}),
|
||||
);
|
||||
expect(failure?.code).toBe('AUTH_006');
|
||||
}
|
||||
|
||||
mail.verifyTwoFactorLogin.mockResolvedValue('authenticated');
|
||||
const blocked = await failureOf(
|
||||
service.login(ARA, PASSWORD, '192.0.2.80', { otpCode: CODE }),
|
||||
);
|
||||
expect(blocked?.code).toBe('REQ_001');
|
||||
expect(blocked?.context).toMatchObject({
|
||||
reason: 'two-factor-code-failures-exceeded',
|
||||
});
|
||||
// 코드 실패는 비밀번호 실패와 따로 센다 — 비밀번호 단계(twoFactorRequired)는 그대로 된다
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, '192.0.2.81'),
|
||||
).resolves.toMatchObject({ status: 'twoFactorRequired' });
|
||||
});
|
||||
|
||||
it('코드가 맞아도 엔진 장애면 MAIL_001 — 코드 실패로 세지 않고 기억하지 않는다 (같은 코드로 다시 할 수 있다)', async () => {
|
||||
mail.verifyTwoFactorLogin.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('MAIL_001');
|
||||
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
).resolves.toMatchObject({ status: 'signedIn' });
|
||||
});
|
||||
|
||||
describe('같은 코드의 동시 요청 (2026-10-31 리뷰 — 두 번째도 signedIn 이었다)', () => {
|
||||
it('첫 로그인이 세션을 저장하는 동안 같은 코드로 들어온 두 번째 로그인은 400 AUTH_006 — 엔진 확인은 한 번', async () => {
|
||||
const write = deferred();
|
||||
sessions.create.mockImplementationOnce(async (session) => {
|
||||
await write.promise;
|
||||
sessions.rows.set(session.id, { ...session });
|
||||
});
|
||||
|
||||
const first = service.login(ARA, PASSWORD, CLIENT_IP, {
|
||||
otpCode: CODE,
|
||||
});
|
||||
await until(() => sessions.create.mock.calls.length === 1);
|
||||
|
||||
const second = await failureOf(
|
||||
service.login(ARA, PASSWORD, '198.51.100.7', { otpCode: CODE }),
|
||||
);
|
||||
write.resolve();
|
||||
|
||||
expect(second?.code).toBe('AUTH_006');
|
||||
await expect(first).resolves.toMatchObject({ status: 'signedIn' });
|
||||
expect(mail.verifyTwoFactorLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('별칭과 정식 주소로 같은 코드를 동시에 보내면 하나만 들어온다 — 확인 중인 코드는 정식 주소로 잡는다', async () => {
|
||||
const ALIAS = 'kim.ara@arasystem.test';
|
||||
mail.getProfile.mockImplementation(() =>
|
||||
Promise.resolve({ email: ARA, name: '김아라' }),
|
||||
);
|
||||
const engine = deferred<'authenticated'>();
|
||||
mail.verifyTwoFactorLogin.mockImplementationOnce(() => engine.promise);
|
||||
|
||||
const first = service.login(ALIAS, PASSWORD, CLIENT_IP, {
|
||||
otpCode: CODE,
|
||||
});
|
||||
await until(() => mail.verifyTwoFactorLogin.mock.calls.length === 1);
|
||||
// 엔진에는 직원이 넣은 이름으로 묻는다 (Basic 확인과 같은 이름)
|
||||
expect(mail.verifyTwoFactorLogin).toHaveBeenCalledWith(
|
||||
ALIAS,
|
||||
PASSWORD,
|
||||
CODE,
|
||||
);
|
||||
|
||||
const second = await failureOf(
|
||||
service.login(ARA, PASSWORD, '198.51.100.8', { otpCode: CODE }),
|
||||
);
|
||||
engine.resolve('authenticated');
|
||||
|
||||
expect(second?.code).toBe('AUTH_006');
|
||||
await expect(first).resolves.toMatchObject({
|
||||
status: 'signedIn',
|
||||
user: { email: ARA },
|
||||
});
|
||||
expect(mail.verifyTwoFactorLogin).toHaveBeenCalledTimes(1);
|
||||
expect(codes.isUsed(ARA, CODE)).toBe(true);
|
||||
expect(codes.isUsed(ALIAS, CODE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('402 의 정식 주소 (엔진은 MFA 판정을 별칭의 권한 판정보다 먼저 한다)', () => {
|
||||
it('막는 계정의 별칭이면 맞는 비밀번호에도 twoFactorRequired 가 아니라 AUTH_004 — 비밀번호 실패로 세고 기록에 남기지 않는다', async () => {
|
||||
mail.getProfile.mockImplementation(() =>
|
||||
Promise.resolve({ email: 'admin@arasystem.test', name: 'admin' }),
|
||||
);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
const failure = await failureOf(
|
||||
service.login('boss@arasystem.test', PASSWORD, `198.51.100.${i}`),
|
||||
);
|
||||
expect(failure?.code).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login('boss@arasystem.test', PASSWORD, '192.0.2.70'),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('메일함을 쓸 수 없는 계정(대리 접근 AUTH_001)도 AUTH_004 — twoFactorRequired 를 주지 않는다', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(
|
||||
new BusinessException('AUTH_001', {
|
||||
context: { reason: 'impersonation-refused' },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
});
|
||||
|
||||
it('별칭으로 들어와도 코드 실패 한도 · 로그인 기록은 정식 주소로 — 별칭으로 5번 틀리면 정식 주소의 맞는 코드도 REQ_001', async () => {
|
||||
const ALIAS = 'kim.ara@arasystem.test';
|
||||
mail.getProfile.mockImplementation(() =>
|
||||
Promise.resolve({ email: ARA, name: '김아라' }),
|
||||
);
|
||||
mail.verifyTwoFactorLogin.mockResolvedValue('code-rejected');
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login(ALIAS, PASSWORD, `198.51.100.${i}`, {
|
||||
otpCode: '000000',
|
||||
}),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ email: ARA, failureReason: 'wrongCode' }),
|
||||
);
|
||||
|
||||
mail.verifyTwoFactorLogin.mockResolvedValue('authenticated');
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login(ARA, PASSWORD, '192.0.2.71', { otpCode: CODE }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
});
|
||||
|
||||
it('otpCode 가 null 이면(검사를 비켜 온 값) 코드를 보내지 않은 것과 같다 — twoFactorRequired, 실패 자리를 남기지 않는다', async () => {
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, `198.51.100.${i}`, {
|
||||
otpCode: null as unknown as string,
|
||||
}),
|
||||
).resolves.toMatchObject({ status: 'twoFactorRequired' });
|
||||
}
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, '192.0.2.72', { otpCode: CODE }),
|
||||
).resolves.toMatchObject({ status: 'signedIn' });
|
||||
});
|
||||
|
||||
it('2단계 인증을 켜지 않은 직원은 코드가 와도 보지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
).resolves.toMatchObject({ status: 'signedIn' });
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isSuccess: true, isTwoFactor: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('앱 비밀번호 모양은 엔진에 묻지 않고 AUTH_004 — 비밀번호 실패로 세고 기록에 비밀번호 틀림으로 남긴다', async () => {
|
||||
const failure = await failureOf(
|
||||
service.login(ARA, APP_PASSWORD, CLIENT_IP),
|
||||
);
|
||||
|
||||
expect(failure?.code).toBe('AUTH_004');
|
||||
expect(failure?.context).toMatchObject({ reason: 'app-password-shape' });
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
}),
|
||||
);
|
||||
// 세었다 — 주소 한도(15분 10회)에 닿으면 맞는 비밀번호도 REQ_001
|
||||
for (let i = 1; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
await failureOf(service.login(ARA, APP_PASSWORD, `198.51.100.${i}`));
|
||||
}
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.90')))?.code,
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('틀린 비밀번호는 기록에 남긴다 · 사내 도메인 밖 · 막은 계정 · 한도로 막은 요청은 남기지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
await failureOf(service.login('ara.kim@gmail.com', 'wrong', CLIENT_IP));
|
||||
await failureOf(
|
||||
service.login('admin@arasystem.test', 'wrong', CLIENT_IP),
|
||||
);
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('액세스 토큰에도 세션 id(sid)를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 막는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
const access = decode(result.tokens!.accessToken);
|
||||
const refresh = decode(result.tokens!.refreshToken);
|
||||
expect(access.sid).toBe(sessions.only().id);
|
||||
expect(access.sid).toBe(refresh.sid);
|
||||
});
|
||||
|
||||
it('갱신하면 그 클라이언트의 IP · User-Agent 로 세션을 고친다 (마지막 활동)', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
const token = await loginToken();
|
||||
|
||||
await service.refresh(token, { ip: '198.51.100.200', userAgent: CHROME });
|
||||
|
||||
expect(sessions.rotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ip: '198.51.100.200', userAgent: CHROME }),
|
||||
);
|
||||
expect(sessions.only()).toMatchObject({
|
||||
ip: '198.51.100.200',
|
||||
userAgent: CHROME,
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호 바꾸기 (2단계 인증을 켠 직원)', () => {
|
||||
const NEW_PASSWORD = 'new-horse-battery-staple';
|
||||
const me = { email: ARA, name: '김아라' };
|
||||
const change = (otpCode?: string, currentPassword = PASSWORD) =>
|
||||
service.changePassword(
|
||||
me,
|
||||
currentPassword,
|
||||
NEW_PASSWORD,
|
||||
CLIENT_IP,
|
||||
undefined,
|
||||
otpCode,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mail.changePassword.mockResolvedValue('changed');
|
||||
});
|
||||
|
||||
it('비밀번호를 먼저 본인 Basic 으로 보고(402) 코드와 함께 엔진에 보낸다 — 바뀌면 앱 비밀번호를 모두 지운다', async () => {
|
||||
await expect(change(CODE)).resolves.toBeUndefined();
|
||||
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
|
||||
expect(mail.changePassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
NEW_PASSWORD,
|
||||
{ otpCode: CODE },
|
||||
);
|
||||
expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
);
|
||||
// 바꾸는 데 쓴 코드는 2분 동안 다시 받지 않는다 — 로그인에서도
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login(ARA, NEW_PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
});
|
||||
|
||||
it('코드가 없으면 400 AUTH_007 — 비밀번호는 맞았으니 비밀번호 실패로 세지 않고, 엔진에 바꾸기를 보내지 않는다', async () => {
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
const failure = await failureOf(change());
|
||||
expect(failure?.code).toBe('AUTH_007');
|
||||
expect(failure?.getStatus()).toBe(400);
|
||||
}
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
expect(
|
||||
appPasswords.removeAllAfterPasswordChange,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('엔진이 코드를 받지 않으면 400 AUTH_006 (코드 실패로 센다) — 앱 비밀번호 · 세션은 그대로', async () => {
|
||||
mail.changePassword.mockResolvedValue('code-rejected');
|
||||
|
||||
const failure = await failureOf(change('000000'));
|
||||
|
||||
expect(failure?.code).toBe('AUTH_006');
|
||||
expect(sessions.revokeAllOf).not.toHaveBeenCalled();
|
||||
expect(
|
||||
appPasswords.removeAllAfterPasswordChange,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('바꾸는 동안(엔진에 보내는 중) 같은 코드로 들어온 로그인은 400 AUTH_006 — 받으면 그 코드는 쓴 코드다', async () => {
|
||||
const ALIAS = 'kim.ara@arasystem.test';
|
||||
mail.getProfile.mockImplementation(() =>
|
||||
Promise.resolve({ email: ARA, name: '김아라' }),
|
||||
);
|
||||
const engine = deferred<'changed'>();
|
||||
mail.changePassword.mockImplementationOnce(() => engine.promise);
|
||||
|
||||
const changing = change(CODE);
|
||||
await until(() => mail.changePassword.mock.calls.length === 1);
|
||||
// 같은 주소의 로그인은 진행 중(REQ_001)이라 별칭으로 — 코드 단계까지 가서 확인 중인 코드에 막힌다
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.login(ALIAS, PASSWORD, '198.51.100.9', { otpCode: CODE }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
|
||||
engine.resolve('changed');
|
||||
await expect(changing).resolves.toBeUndefined();
|
||||
expect(codes.isUsed(ARA, CODE)).toBe(true);
|
||||
});
|
||||
|
||||
it('엔진이 새 비밀번호를 거절하면(400 VAL_001) 코드는 쓰지 않은 것으로 남는다 — 비밀번호만 고쳐 같은 코드로 바꾼다', async () => {
|
||||
mail.changePassword.mockResolvedValueOnce('new-password-rejected');
|
||||
expect((await failureOf(change(CODE)))?.code).toBe('VAL_001');
|
||||
expect(codes.isUsed(ARA, CODE)).toBe(false);
|
||||
|
||||
await expect(change(CODE)).resolves.toBeUndefined();
|
||||
expect(codes.isUsed(ARA, CODE)).toBe(true);
|
||||
});
|
||||
|
||||
it('결과를 모르게 끝나면(503) 그 코드는 쓴 것으로 둔다 — 엔진이 바꿨을 수 있다', async () => {
|
||||
mail.changePassword.mockResolvedValueOnce('outcome-unknown');
|
||||
expect((await failureOf(change(CODE)))?.code).toBe('MAIL_001');
|
||||
expect((await failureOf(change(CODE)))?.code).toBe('AUTH_006');
|
||||
});
|
||||
|
||||
it('otpCode 가 null 이면(검사를 비켜 온 값) 코드가 없는 것과 같다 — 400 AUTH_007', async () => {
|
||||
expect((await failureOf(change(null as unknown as string)))?.code).toBe(
|
||||
'AUTH_007',
|
||||
);
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('같은 직원의 2단계 인증 바꾸기(등록 · 켜기 · 끄기)가 처리 중이면 429 REQ_001', async () => {
|
||||
const release = twoFactorLocks.acquire(ARA);
|
||||
try {
|
||||
expect((await failureOf(change(CODE)))?.code).toBe('REQ_001');
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await expect(change(CODE)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('결과를 모르게 끝나도(503) 앱 비밀번호를 지운다 — 다른 세션을 끊는 규칙과 같다', async () => {
|
||||
mail.changePassword.mockResolvedValue('outcome-unknown');
|
||||
|
||||
const failure = await failureOf(change(CODE));
|
||||
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
);
|
||||
expect(failure?.context).toMatchObject({ removedAppPasswords: 0 });
|
||||
});
|
||||
|
||||
it('앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 — 세지 않는다', async () => {
|
||||
const appPassword = `app_${'k'.repeat(36)}`;
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
const failure = await failureOf(change(CODE, appPassword));
|
||||
expect(failure?.code).toBe('AUTH_005');
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'app-password-shape',
|
||||
});
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
await expect(change(CODE)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('2단계 인증을 켜지 않은 직원이면(본인 Basic 200) 코드가 와도 본인 자격으로 바꾼다 — 앱 비밀번호는 지운다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
|
||||
await expect(change(CODE)).resolves.toBeUndefined();
|
||||
|
||||
expect(mail.changePassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
NEW_PASSWORD,
|
||||
);
|
||||
expect(appPasswords.removeAllAfterPasswordChange).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
);
|
||||
});
|
||||
|
||||
it('그사이 다른 곳에서 2단계 인증을 켰으면(본인 자격의 바꾸기가 402) 400 AUTH_007', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
mail.changePassword.mockResolvedValue('two-factor-required');
|
||||
|
||||
expect((await failureOf(change()))?.code).toBe('AUTH_007');
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면(본인 Basic 401) 엔진에 바꾸기를 보내지 않고 AUTH_005 — 로그인 실패로 센다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.changePassword(
|
||||
me,
|
||||
'wrong',
|
||||
NEW_PASSWORD,
|
||||
`198.51.100.${i}`,
|
||||
undefined,
|
||||
CODE,
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_005');
|
||||
}
|
||||
expect(mail.changePassword).not.toHaveBeenCalled();
|
||||
expect((await failureOf(change(CODE)))?.code).toBe('REQ_001');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('로그아웃', () => {
|
||||
it('서버의 세션을 끊는다 — 그 뒤 리프레시는 AUTH_001', async () => {
|
||||
const token = await loginToken();
|
||||
|
||||
@@ -4,6 +4,17 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isAppPasswordShape } from '@/modules/account-security/app-password';
|
||||
import { AppPasswordsService } from '@/modules/account-security/app-passwords.service';
|
||||
import {
|
||||
LoginEventsService,
|
||||
type WebmailLoginAttempt,
|
||||
} from '@/modules/account-security/login-events.service';
|
||||
import {
|
||||
type CodeCheck,
|
||||
TwoFactorCodeGuard,
|
||||
} from '@/modules/account-security/two-factor-code-guard';
|
||||
import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks';
|
||||
import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
@@ -13,6 +24,7 @@ import type {
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
import type {
|
||||
AuthSessionEntity,
|
||||
@@ -47,6 +59,36 @@ export interface AuthResult {
|
||||
readonly tokens: SessionTokens | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 결과 (계약 5절 "로그인") — 2단계 인증을 켠 직원은 비밀번호가 맞으면 코드 단계(twoFactorRequired — 쿠키 없음)를 거친다
|
||||
*/
|
||||
export type LoginResult =
|
||||
| {
|
||||
readonly status: 'signedIn';
|
||||
readonly user: AuthenticatedUser;
|
||||
readonly tokens: SessionTokens;
|
||||
}
|
||||
| {
|
||||
readonly status: 'twoFactorRequired';
|
||||
readonly user: null;
|
||||
readonly tokens: null;
|
||||
};
|
||||
|
||||
/** 로그인 · 갱신한 클라이언트 — 로그인된 브라우저 목록 · 로그인 기록 (계약 5절) */
|
||||
export interface ClientInfo {
|
||||
/** TRUST_PROXY 기준 클라이언트 IP — 모르면 빈 글 */
|
||||
readonly ip: string;
|
||||
/** User-Agent 원문 — 없으면 null */
|
||||
readonly userAgent?: string | null;
|
||||
}
|
||||
|
||||
/** 로그인의 선택 값 */
|
||||
export interface LoginOptions {
|
||||
/** 2단계 인증 코드 (숫자 6자리 — 형식은 DTO 가 본다) */
|
||||
readonly otpCode?: string;
|
||||
readonly userAgent?: string | null;
|
||||
}
|
||||
|
||||
/** 리프레시 토큰에서 꺼낸 값 */
|
||||
interface RefreshClaims {
|
||||
readonly sub: string;
|
||||
@@ -65,6 +107,21 @@ function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 직원의 비밀번호 바꾸기 결과 → 코드 확인 결과 (TwoFactorCodeGuard).
|
||||
* 바뀜 · 결과 모름(엔진이 바꿨을 수 있다)은 코드를 쓴 것으로, 코드 틀림은 실패로, 그 밖(새 비밀번호 거절 등 — 바뀐 것이 없다)은
|
||||
* 코드를 쓰지 않은 것으로 둔다 — 비밀번호만 고쳐 같은 코드로 다시 보낼 수 있게
|
||||
*/
|
||||
function codeCheckOfPasswordChange(
|
||||
outcome: PasswordChangeOutcome,
|
||||
): CodeCheck<PasswordChangeOutcome> {
|
||||
if (outcome === 'code-rejected') return { kind: 'rejected' };
|
||||
if (outcome === 'changed' || outcome === 'outcome-unknown') {
|
||||
return { kind: 'accepted', value: outcome };
|
||||
}
|
||||
return { kind: 'unused', value: outcome };
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 로그인 — 직원 계정 = 메일 계정.
|
||||
* 비밀번호는 로그인할 때만 메일 엔진에 확인하고 저장하지 않는다.
|
||||
@@ -84,39 +141,99 @@ export class AuthService {
|
||||
private readonly attempts: LoginAttemptService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
private readonly directory: MailDirectoryService,
|
||||
/** 12단계 — 2단계 인증 코드의 한도 · 다시 쓰기 막기, 같은 직원의 2단계 바꾸기 잠금, 로그인 기록, 앱 비밀번호 */
|
||||
private readonly codes: TwoFactorCodeGuard,
|
||||
private readonly twoFactorLocks: TwoFactorLocks,
|
||||
private readonly loginEvents: LoginEventsService,
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 계정 없음·비밀번호 틀림·사내 도메인이 아닌 주소는 구분하지 않고 AUTH_004 (.claude/rules/security.md).
|
||||
* 메일 엔진에 묻기 **전에** 실패 한 번을 IP·주소에 미리 차지한다 — 동시 요청으로 한도를 넘지 못하게 (LoginAttemptService).
|
||||
* 성공·엔진 장애는 차지한 자리를 돌려준다 (성공은 세지 않는다).
|
||||
*
|
||||
* 2단계 인증 (계약 5절 "로그인") — 서버의 차례:
|
||||
* 1. 앱 비밀번호 모양이면 엔진에 묻지 않고 실패로 센다 (AUTH_004 — 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다)
|
||||
* 2. 실패 한 번을 차지하고 엔진에 본인 Basic 으로 묻는다
|
||||
* 3. 맞음 → 2단계 인증이 꺼진 직원 — 로그인한다(코드가 와도 보지 않는다)
|
||||
* 4. 402(비밀번호는 맞고 엔진이 코드를 요구) → 402 는 정식 주소를 알려 주지 않으므로 대리 접근 프로필로 정식 주소를 먼저 얻는다 —
|
||||
* 막는 계정이면(별칭으로 들어온 관리자 등 — 엔진은 MFA 판정을 별칭의 권한 판정보다 먼저 한다) AUTH_004(비밀번호 실패로 센다).
|
||||
* 그다음 차지한 비밀번호 실패를 돌려준다. 코드가 없으면 twoFactorRequired(쿠키 없음).
|
||||
* 있으면 다시 쓴 코드 · 확인 중인 같은 코드인지 보고 → 코드 실패 한 번을 차지하고 → 엔진의 로그인 요청(mfaToken)에 묻는다.
|
||||
* 맞으면 그 자리에서 코드를 2분 기억하고 로그인한다. 틀리면 400 AUTH_006 (코드 실패 — 같은 주소 15분 5회 · 24시간 20회).
|
||||
* 코드의 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다
|
||||
* 5. 틀림 → AUTH_004
|
||||
* 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006)는 로그인 기록에 남긴다 — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
clientIp: string,
|
||||
): Promise<AuthResult> {
|
||||
options: LoginOptions = {},
|
||||
): Promise<LoginResult> {
|
||||
const address = normalizeEmail(email);
|
||||
if (!this.mailSettings.domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
const client: ClientInfo = {
|
||||
ip: clientIp,
|
||||
userAgent: options.userAgent ?? null,
|
||||
};
|
||||
|
||||
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
|
||||
// 로그인할 수 없는 주소(사내 도메인 밖·게이트웨이·관리자)는 엔진에 묻지 않고 바로 거절하므로 주소 카운터에 넣지 않는다(IP 만 센다) —
|
||||
// 아무 주소나 뿌려 주소 카운터를 채우지 못하게. 응답은 같은 AUTH_004 라 이 차이로 드러나는 것은 없다
|
||||
const loginAddress = this.isLoginAddress(address);
|
||||
const ticket = this.attempts.begin({
|
||||
email: this.isLoginAddress(address) ? address : null,
|
||||
email: loginAddress ? address : null,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let profile: MailAccountProfile;
|
||||
let isTwoFactor = false;
|
||||
try {
|
||||
profile = await this.authenticate(address, password);
|
||||
if (!loginAddress) {
|
||||
throw this.loginFailed(address, 'not-employee-address');
|
||||
}
|
||||
if (isAppPasswordShape(password)) {
|
||||
ticket.fail();
|
||||
await this.recordLoginFailure(address, client, 'wrongPassword');
|
||||
throw this.loginFailed(address, 'app-password-shape');
|
||||
}
|
||||
|
||||
const check = await this.mail.verifyCredentials(address, password);
|
||||
if (check.kind === 'invalid') {
|
||||
ticket.fail();
|
||||
await this.recordLoginFailure(address, client, 'wrongPassword');
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
if (check.kind === 'valid') {
|
||||
profile = await this.profileOf(check.account, address);
|
||||
} else {
|
||||
// 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다)
|
||||
profile = await this.profileOf(address, address);
|
||||
// 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
|
||||
ticket.verified();
|
||||
if (options.otpCode == null) {
|
||||
ticket.abort();
|
||||
return { status: 'twoFactorRequired', user: null, tokens: null };
|
||||
}
|
||||
await this.verifyLoginCode(
|
||||
normalizeEmail(profile.email),
|
||||
address,
|
||||
password,
|
||||
options.otpCode,
|
||||
client,
|
||||
);
|
||||
isTwoFactor = true;
|
||||
}
|
||||
ticket.succeed();
|
||||
} catch (error) {
|
||||
// AUTH_004 로 응답하는 경우만 실패로 센다 — 엔진 장애(MAIL_001)·엔진 한도(REQ_001)는 사용자 잘못이 아니다
|
||||
// (위에서 이미 fail 했으면 두 번째 부름은 무시된다. 코드 단계의 실패는 코드 한도가 따로 셌다)
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_004') {
|
||||
ticket.fail();
|
||||
} else {
|
||||
@@ -125,10 +242,84 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile);
|
||||
const tokens = await this.startSession(profile, client);
|
||||
await this.recordInDirectory(profile);
|
||||
this.logger.log(`로그인: ${maskEmail(profile.email)}`);
|
||||
return { user: toUser(profile), tokens };
|
||||
await this.loginEvents.recordWebmail({
|
||||
email: profile.email,
|
||||
isSuccess: true,
|
||||
isTwoFactor,
|
||||
failureReason: null,
|
||||
ip: client.ip || null,
|
||||
userAgent: client.userAgent ?? null,
|
||||
});
|
||||
this.logger.log(
|
||||
`로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}`,
|
||||
);
|
||||
return { status: 'signedIn', user: toUser(profile), tokens };
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 로그인의 코드 단계 — 다시 쓴 코드 · 확인 중인 같은 코드 · 코드 실패 한도(REQ_001) → 엔진의 로그인 요청(비밀번호 + 코드).
|
||||
* 한도 · 기억 · 기록은 정식 주소(`account`)로, 엔진에는 직원이 넣은 이름(`loginName` — Basic 확인과 같은 이름)으로 묻는다.
|
||||
* 틀리면 로그인 기록에 남기고 400 AUTH_006. 받은 코드는 그 자리에서 기억한다(뒤의 세션 저장이 실패해도 — 같은 코드로 두 번 들어오지 못하게)
|
||||
*/
|
||||
private async verifyLoginCode(
|
||||
account: string,
|
||||
loginName: string,
|
||||
password: string,
|
||||
otpCode: string,
|
||||
client: ClientInfo,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.codes.check(
|
||||
{ email: account, ip: client.ip || 'unknown' },
|
||||
otpCode,
|
||||
async () =>
|
||||
(await this.mail.verifyTwoFactorLogin(
|
||||
loginName,
|
||||
password,
|
||||
otpCode,
|
||||
)) === 'authenticated'
|
||||
? { kind: 'accepted' as const, value: undefined }
|
||||
: { kind: 'rejected' as const },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_006') {
|
||||
await this.recordLoginFailure(account, client, 'wrongCode');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 엔진이 알려 준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다. 메일함에 접근할 수 없으면 AUTH_004 */
|
||||
private async profileOf(
|
||||
account: string,
|
||||
address: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
if (!this.isLoginAddress(account)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
const profile = await this.loadProfileForLogin(account, address);
|
||||
if (!this.isLoginAddress(profile.email)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */
|
||||
private async recordLoginFailure(
|
||||
address: string,
|
||||
client: ClientInfo,
|
||||
failureReason: NonNullable<WebmailLoginAttempt['failureReason']>,
|
||||
): Promise<void> {
|
||||
await this.loginEvents.recordWebmail({
|
||||
email: address,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
failureReason,
|
||||
ip: client.ip || null,
|
||||
userAgent: client.userAgent ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +348,10 @@ export class AuthService {
|
||||
* - 계정이 아직 살아 있는지 메일 엔진에 다시 확인한다 (사라졌으면 세션을 끊고 AUTH_001)
|
||||
* - 메일 엔진 장애는 MAIL_001 그대로 — 회전하기 전에 확인하므로 같은 토큰으로 다시 시도할 수 있다
|
||||
*/
|
||||
async refresh(refreshToken: string | undefined): Promise<AuthResult> {
|
||||
async refresh(
|
||||
refreshToken: string | undefined,
|
||||
client: ClientInfo = { ip: '' },
|
||||
): Promise<AuthResult> {
|
||||
if (!refreshToken) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'refresh-token-missing' },
|
||||
@@ -175,23 +369,30 @@ export class AuthService {
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password") — 메일 엔진에 그 직원 **본인의 자격**(지금 비밀번호)으로 보낸다.
|
||||
* 비밀번호 바꾸기 (계약 1절 "PUT /api/auth/password" · 5절 "비밀번호 바꾸기").
|
||||
*
|
||||
* - 새 비밀번호가 지금과 같으면 엔진에 묻지 않고 VAL_001
|
||||
* - 막는 계정(관리자 등 로그인할 수 없는 주소)은 엔진에 묻지 않고 AUTH_001 — 웹메일로 다루지 않는 계정이다
|
||||
* - 지금 비밀번호 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고
|
||||
* - 앱 비밀번호 모양의 지금 비밀번호는 엔진에 묻지 않고 AUTH_005 (세지 않는다)
|
||||
* - 지금 비밀번호를 먼저 본인 Basic 으로 확인한다 — 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은 forbidden 으로 거절하기 때문이다.
|
||||
* 틀림은 로그인 실패와 **같은 한도 · 같은 카운터**로 센다 — 엔진에 묻기 전에 실패 한 번을 IP·주소에 차지하고
|
||||
* (LoginAttemptService), 틀리면 남기고(400 AUTH_005 — 401 이 아니다: 클라이언트는 401 이면 갱신하고 다시 보낸다),
|
||||
* 바뀌면 로그인 성공처럼 돌려준다. 같은 주소의 로그인·변경이 처리 중이면 REQ_001
|
||||
* - 맞음(2단계 꺼짐) → 본인 자격으로 바꾼다
|
||||
* - 402(2단계 켜짐, 비밀번호 맞음) → 코드가 없으면 400 AUTH_007(다른 탭에서 켠 경우). 있으면 같은 직원의 2단계 바꾸기 잠금을 잡고
|
||||
* 다시 쓴 코드 · 코드 실패 한도를 본 뒤 대리 접근으로 지금 비밀번호 · 새 비밀번호 · 코드를 보낸다 — 틀리면 400 AUTH_006(코드 실패로 센다).
|
||||
* 2단계 인증은 그대로 남는다
|
||||
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
|
||||
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊는다 — 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다.
|
||||
* 쿠키로 이 세션을 알 수 없으면 모두 끊는다 (이 탭도 액세스 토큰이 끝나면 다시 로그인한다).
|
||||
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 다른 세션은 끊어 두고 503 MAIL_001 로 답한다.
|
||||
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊고(끊은 세션의 액세스 토큰은 가드가 바로 막는다) **앱 비밀번호를 모두 지운다** —
|
||||
* 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. 쿠키로 이 세션을 알 수 없으면 모두 끊는다.
|
||||
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 둘 다 해 두고 503 MAIL_001 로 답한다. 끊기 · 지우기에 실패해도 던지지 않는다(error 로그).
|
||||
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
|
||||
* 비밀번호는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 수만.
|
||||
* 비밀번호 · 코드는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 · 지운 앱 비밀번호 수만.
|
||||
*/
|
||||
async changePassword(
|
||||
user: AuthenticatedUser,
|
||||
@@ -199,6 +400,7 @@ export class AuthService {
|
||||
newPassword: string,
|
||||
clientIp: string,
|
||||
refreshToken: string | undefined,
|
||||
otpCode?: string,
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(user.email);
|
||||
if (newPassword === currentPassword) {
|
||||
@@ -219,47 +421,91 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isAppPasswordShape(currentPassword)) {
|
||||
// 앱 비밀번호로는 바꾸지 못한다 — 엔진에 묻지 않고, 맞혀 보기가 아니라 세지 않는다 (4단계 규칙과 같다)
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
}
|
||||
|
||||
// 로그인과 같은 카운터 — 지금 비밀번호를 맞혀 보는 창구가 로그인 한도를 비켜 가지 못하게
|
||||
const ticket = this.attempts.begin({
|
||||
email: address,
|
||||
ip: clientIp || 'unknown',
|
||||
});
|
||||
|
||||
let outcome: PasswordChangeOutcome;
|
||||
// 2단계 인증을 켠 직원이면 같은 직원의 2단계 바꾸기(등록 · 켜기 · 끄기)와 겹치지 않게 잠근다
|
||||
let releaseLock: (() => void) | null = null;
|
||||
try {
|
||||
outcome = await this.mail.changePassword(
|
||||
address,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
} catch (error) {
|
||||
// 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) — 바뀌지 않았고 사용자 잘못도 아니다. 세지 않는다
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
let outcome: PasswordChangeOutcome;
|
||||
try {
|
||||
const check = await this.mail.verifyCredentials(
|
||||
address,
|
||||
currentPassword,
|
||||
);
|
||||
if (check.kind === 'invalid') {
|
||||
outcome = 'current-password-rejected';
|
||||
} else if (check.kind === 'valid') {
|
||||
outcome = await this.mail.changePassword(
|
||||
address,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
} else {
|
||||
// 402 — 비밀번호는 맞았다 (코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다)
|
||||
ticket.verified();
|
||||
if (otpCode == null) {
|
||||
throw this.otpCodeRequired(address);
|
||||
}
|
||||
releaseLock = this.twoFactorLocks.acquire(address);
|
||||
outcome = await this.codes.check<PasswordChangeOutcome>(
|
||||
{ email: address, ip: clientIp || 'unknown' },
|
||||
otpCode,
|
||||
async () => {
|
||||
const result = await this.mail.changePassword(
|
||||
address,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
{ otpCode },
|
||||
);
|
||||
return codeCheckOfPasswordChange(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// 결과가 확실한 엔진 장애(MAIL_001) · 엔진 한도(REQ_001) · 코드 단계의 실패(AUTH_006 · AUTH_007 — 코드 한도가 따로 셌다)는
|
||||
// 비밀번호 맞혀 보기가 아니다. 세지 않는다
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.settlePasswordChange(outcome, ticket, address, refreshToken);
|
||||
} finally {
|
||||
releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기) */
|
||||
private async settlePasswordChange(
|
||||
outcome: PasswordChangeOutcome,
|
||||
ticket: LoginAttemptTicket,
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<void> {
|
||||
switch (outcome) {
|
||||
case 'current-password-rejected':
|
||||
ticket.fail();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-rejected',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
throw this.currentPasswordRejected(
|
||||
address,
|
||||
'current-password-rejected',
|
||||
);
|
||||
case 'current-password-refused':
|
||||
// 같은 값으로 엔진에 들어가기는 했다 — 맞혀 보기가 아니라 세지 않는다 (변경 권한을 빼면 맞는 비밀번호로 로그인까지 막히지 않게).
|
||||
// 엔진의 거절 종류는 어댑터가 warn 으로 남겼다
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_005', {
|
||||
context: {
|
||||
reason: 'current-password-refused-by-mail-engine',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
throw this.currentPasswordRejected(
|
||||
address,
|
||||
'current-password-refused-by-mail-engine',
|
||||
);
|
||||
case 'new-password-rejected':
|
||||
// 지금 비밀번호는 맞았다 — 실패로 세지 않는다
|
||||
// 지금 비밀번호(와 코드)는 맞았다 — 실패로 세지 않는다
|
||||
ticket.abort();
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
@@ -267,9 +513,22 @@ export class AuthService {
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'two-factor-required':
|
||||
// 그사이 다른 곳에서 2단계 인증을 켰다 — 화면은 코드 칸을 보인다
|
||||
ticket.abort();
|
||||
throw this.otpCodeRequired(address);
|
||||
case 'code-rejected':
|
||||
// 코드 단계(codes.check)가 이미 AUTH_006 으로 바꾼다 — 여기는 빠진 길이 없게
|
||||
ticket.abort();
|
||||
throw new BusinessException('AUTH_006', {
|
||||
context: {
|
||||
reason: 'two-factor-code-rejected',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
case 'outcome-unknown': {
|
||||
// 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션은 끊어 두고 실패로 답한다 (이 세션은 남긴다)
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
// 엔진이 바꿨을 수 있다 — 탈취가 의심돼 바꾸는 중일 수 있으니 다른 세션 · 앱 비밀번호는 정리해 두고 실패로 답한다 (이 세션은 남긴다)
|
||||
const cleaned = await this.cleanUpBeforeSettling(
|
||||
ticket,
|
||||
'abort',
|
||||
address,
|
||||
@@ -280,12 +539,13 @@ export class AuthService {
|
||||
context: {
|
||||
reason: 'password-change-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
revokedOtherSessions: revoked,
|
||||
revokedOtherSessions: cleaned.revokedSessions,
|
||||
removedAppPasswords: cleaned.removedAppPasswords,
|
||||
},
|
||||
});
|
||||
}
|
||||
case 'changed': {
|
||||
const revoked = await this.revokeBeforeSettling(
|
||||
const cleaned = await this.cleanUpBeforeSettling(
|
||||
ticket,
|
||||
'succeed',
|
||||
address,
|
||||
@@ -293,31 +553,57 @@ export class AuthService {
|
||||
'password-changed',
|
||||
);
|
||||
this.logger.log(
|
||||
`비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${revoked}개`,
|
||||
`비밀번호 변경: ${maskEmail(address)}, 끊은 다른 세션 ${cleaned.revokedSessions}개, 지운 앱 비밀번호 ${cleaned.removedAppPasswords}개`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 세션을 끊은 **뒤에** 시도를 끝낸다 → 끊은 수. 끊는 동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀,
|
||||
* 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 끊다 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게)
|
||||
* 다른 세션을 끊고 앱 비밀번호를 지운 **뒤에** 시도를 끝낸다 → 끊은 수 · 지운 수. 그동안은 같은 주소의 로그인이 진행 중(REQ_001)으로 막혀,
|
||||
* 새 비밀번호로 막 로그인한 세션이 함께 끊기지 않는다. 실패해도 시도는 반드시 끝낸다 (그 주소가 계속 막히지 않게)
|
||||
*/
|
||||
private async revokeBeforeSettling(
|
||||
private async cleanUpBeforeSettling(
|
||||
ticket: LoginAttemptTicket,
|
||||
settle: 'succeed' | 'abort',
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
): Promise<{ revokedSessions: number; removedAppPasswords: number }> {
|
||||
try {
|
||||
return await this.revokeOtherSessions(address, refreshToken, reason);
|
||||
const revokedSessions = await this.revokeOtherSessions(
|
||||
address,
|
||||
refreshToken,
|
||||
reason,
|
||||
);
|
||||
const removedAppPasswords =
|
||||
await this.appPasswords.removeAllAfterPasswordChange(address);
|
||||
return { revokedSessions, removedAppPasswords };
|
||||
} finally {
|
||||
if (settle === 'succeed') ticket.succeed();
|
||||
else ticket.abort();
|
||||
}
|
||||
}
|
||||
|
||||
private currentPasswordRejected(
|
||||
address: string,
|
||||
reason: string,
|
||||
): BusinessException {
|
||||
return new BusinessException('AUTH_005', {
|
||||
context: { reason, email: maskEmail(address) },
|
||||
});
|
||||
}
|
||||
|
||||
/** 2단계 인증을 켠 직원의 요청에 코드가 없다 — 400 AUTH_007 */
|
||||
private otpCodeRequired(address: string): BusinessException {
|
||||
return new BusinessException('AUTH_007', {
|
||||
context: {
|
||||
reason: 'two-factor-code-required',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
|
||||
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
|
||||
@@ -328,7 +614,7 @@ export class AuthService {
|
||||
refreshToken: string | undefined,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
const keepId = await this.sessionIdOf(refreshToken, address);
|
||||
const keepId = await this.currentSessionId(refreshToken, address);
|
||||
try {
|
||||
return await this.sessions.revokeAllOf(
|
||||
address,
|
||||
@@ -344,8 +630,11 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다) */
|
||||
private async sessionIdOf(
|
||||
/**
|
||||
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
|
||||
* 비밀번호 바꾸기 · 로그인된 브라우저 목록(지금 세션 — 계약 5절)이 쓴다
|
||||
*/
|
||||
async currentSessionId(
|
||||
refreshToken: string | undefined,
|
||||
address: string,
|
||||
): Promise<string | null> {
|
||||
@@ -395,22 +684,6 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
private async authenticate(
|
||||
address: string,
|
||||
password: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
if (!this.isLoginAddress(address)) {
|
||||
throw this.loginFailed(address, 'not-employee-address');
|
||||
}
|
||||
|
||||
const account = await this.mail.verifyCredentials(address, password);
|
||||
// 엔진이 돌려준 정식 주소로 한 번 더 — 별칭으로 들어와도 관리자·게이트웨이 계정이면 막는다
|
||||
if (!account || !this.isLoginAddress(account)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
return this.loadProfileForLogin(account, address);
|
||||
}
|
||||
|
||||
private classify(
|
||||
session: AuthSessionEntity | null,
|
||||
claims: RefreshClaims,
|
||||
@@ -432,9 +705,10 @@ export class AuthService {
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
client: ClientInfo,
|
||||
): Promise<AuthResult> {
|
||||
if (state === 'current' && session) {
|
||||
return this.rotate(session, claims, presentedHash, now);
|
||||
return this.rotate(session, claims, presentedHash, now, client);
|
||||
}
|
||||
|
||||
if (state === 'grace') {
|
||||
@@ -459,6 +733,7 @@ export class AuthService {
|
||||
claims: RefreshClaims,
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
client: ClientInfo,
|
||||
): Promise<AuthResult> {
|
||||
const profile = await this.loadProfileForRefresh(claims, now);
|
||||
const expiresAt = nextIdleExpiry(
|
||||
@@ -474,6 +749,8 @@ export class AuthService {
|
||||
nextHash: hashRefreshToken(tokens.refreshToken),
|
||||
nextExpiresAt: expiresAt,
|
||||
now,
|
||||
ip: client.ip || null,
|
||||
userAgent: truncateUserAgent(client.userAgent),
|
||||
});
|
||||
if (rotated) return { user: toUser(profile), tokens };
|
||||
|
||||
@@ -489,7 +766,14 @@ export class AuthService {
|
||||
context: { reason: 'rotation-conflict', sid: claims.sid },
|
||||
});
|
||||
}
|
||||
return this.resolveRefresh(state, latest, claims, presentedHash, now);
|
||||
return this.resolveRefresh(
|
||||
state,
|
||||
latest,
|
||||
claims,
|
||||
presentedHash,
|
||||
now,
|
||||
client,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계정이 사라졌으면 세션을 끊는다. 메일 엔진 장애는 그대로 던진다 (세션은 남는다) */
|
||||
@@ -522,9 +806,10 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간 */
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록) */
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
client: ClientInfo,
|
||||
): Promise<SessionTokens> {
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
@@ -549,6 +834,8 @@ export class AuthService {
|
||||
absoluteExpiresAt,
|
||||
revokedAt: null,
|
||||
revokedReason: null,
|
||||
ip: client.ip || null,
|
||||
userAgent: truncateUserAgent(client.userAgent),
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
@@ -603,10 +890,12 @@ export class AuthService {
|
||||
1,
|
||||
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
|
||||
);
|
||||
// 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저")
|
||||
const access: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'access',
|
||||
name: profile.name,
|
||||
sid: sessionId,
|
||||
};
|
||||
const refresh: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { DeviceDto } from '@/modules/account-security/dto/login-event.dto';
|
||||
|
||||
/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */
|
||||
export class AuthSessionDto {
|
||||
@ApiProperty({
|
||||
description: '세션 id — 끊기에 쓴다',
|
||||
example: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '로그인한 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:12:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 활동 — 마지막 갱신(회전) 때, 없으면 로그인 때',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T02:40:00.000Z',
|
||||
})
|
||||
lastActiveAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '로그인 · 갱신한 클라이언트 IP — 12단계 전에 연 세션은 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'브라우저 · 운영체제의 짧은 이름 — 12단계 전에 연 세션이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
device!: DeviceDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 — 맨 앞에 온다. 쿠키가 없거나 남의 것이면 모두 false',
|
||||
example: true,
|
||||
})
|
||||
isCurrent!: boolean;
|
||||
}
|
||||
|
||||
export class AuthSessionPageDto {
|
||||
@ApiProperty({
|
||||
type: [AuthSessionDto],
|
||||
description: '지금 세션이 맨 앞, 그다음 최근 활동 차례',
|
||||
})
|
||||
items!: AuthSessionDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'total — 끝나지 않은 세션 수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** DELETE /api/auth/sessions → 200 */
|
||||
export class EndedSessionsDto {
|
||||
@ApiProperty({ description: '끊은 세션 수', example: 2 })
|
||||
ended!: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import {
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
NEW_PASSWORD_MAX_LENGTH,
|
||||
NEW_PASSWORD_MIN_LENGTH,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { OptionalOtpCodeField } from '@/modules/auth/dto/security-fields';
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password — 계약 1절.
|
||||
* PUT /api/auth/password — 계약 1절 · 5절 "비밀번호 바꾸기"(2단계 인증을 켠 직원은 otpCode).
|
||||
* 지금 비밀번호는 로그인(dto/login.dto.ts)과 같은 검사 — 로그인할 수 있었던 비밀번호는 여기서도 통과하고, 그보다 긴 값은 들어올 수 없다.
|
||||
* 새 비밀번호의 길이는 JS 문자열 길이로 센다(웹 클라이언트와 같다). 지금 비밀번호와 같으면 서비스가 400 VAL_001 (엔진에 묻지 않는다).
|
||||
* 비밀번호는 저장하지 않고 로그에 남기지 않는다.
|
||||
@@ -44,4 +45,12 @@ export class ChangePasswordDto {
|
||||
message: `새 비밀번호는 ${NEW_PASSWORD_MAX_LENGTH}자 이하여야 합니다.`,
|
||||
})
|
||||
newPassword!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'인증 앱 코드(숫자 6자리) — 2단계 인증을 켠 직원만 필수 (없으면 400 AUTH_007, 틀리거나 2분 안에 다시 쓴 코드면 400 AUTH_006 — 계약 5절)',
|
||||
example: '123456',
|
||||
})
|
||||
@OptionalOtpCodeField()
|
||||
otpCode?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* POST /api/auth/login 의 응답 (계약 5절 "로그인") — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다.
|
||||
* twoFactorRequired 는 비밀번호가 맞았다는 뜻이다 — 쿠키가 없고 email · name 은 null (코드와 비밀번호를 함께 다시 보낸다)
|
||||
*/
|
||||
export class LoginResultDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'signedIn — 로그인했다(쿠키를 구웠다) · twoFactorRequired — 2단계 인증 코드가 필요하다(쿠키 없음)',
|
||||
enum: ['signedIn', 'twoFactorRequired'],
|
||||
example: 'signedIn',
|
||||
})
|
||||
status!: 'signedIn' | 'twoFactorRequired';
|
||||
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소 — twoFactorRequired 면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
email!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '표시 이름 — twoFactorRequired 면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '김아라',
|
||||
})
|
||||
name!: string | null;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { OptionalOtpCodeField } from '@/modules/auth/dto/security-fields';
|
||||
import { MAX_EMAIL_LENGTH } from '@/modules/mail/mail.constants';
|
||||
|
||||
/** POST /api/auth/login — 직원 메일 계정 */
|
||||
/** POST /api/auth/login — 직원 메일 계정 (계약 1절 · 5절 "로그인") */
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소 (사내 도메인)',
|
||||
@@ -15,7 +16,8 @@ export class LoginDto {
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다',
|
||||
description:
|
||||
'메일 계정 비밀번호 — 메일 엔진에 확인만 하고 저장하지 않는다. 코드 단계에서도 다시 싣는다(서버는 비밀번호를 들고 있지 않다). 앱 비밀번호는 받지 않는다',
|
||||
format: 'password',
|
||||
example: 'your-password',
|
||||
})
|
||||
@@ -23,4 +25,12 @@ export class LoginDto {
|
||||
@IsNotEmpty({ message: '비밀번호를 입력해 주세요.' })
|
||||
@MaxLength(MAX_PASSWORD_LENGTH, { message: '비밀번호가 너무 깁니다.' })
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'인증 앱 코드(숫자 6자리) — 2단계 인증을 켠 직원의 둘째 단계에서만. 2단계 인증을 켜지 않은 직원이면 보지 않는다. 칸을 빼고 보낸다(null 은 400 VAL_001)',
|
||||
example: '123456',
|
||||
})
|
||||
@OptionalOtpCodeField()
|
||||
otpCode?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { createValidationPipe } from '@/app.setup';
|
||||
import {
|
||||
TwoFactorActivationDto,
|
||||
TwoFactorDeactivationDto,
|
||||
} from '@/modules/account-security/dto/two-factor.dto';
|
||||
import { ChangePasswordDto } from './change-password.dto';
|
||||
import { LoginDto } from './login.dto';
|
||||
|
||||
/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */
|
||||
async function through(
|
||||
metatype: new () => object,
|
||||
body: object,
|
||||
): Promise<object | 'VAL_001'> {
|
||||
try {
|
||||
return (await createValidationPipe().transform(body, {
|
||||
type: 'body',
|
||||
metatype,
|
||||
})) as object;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) return 'VAL_001';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe('인증 앱 코드 칸 (계약 5절 — otpCode)', () => {
|
||||
const login = { email: 'ara.kim@arasystem.test', password: 'pw-1234' };
|
||||
const change = { currentPassword: 'old-password', newPassword: 'new-pass' };
|
||||
const activation = {
|
||||
enrollmentId: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
currentPassword: 'old-password',
|
||||
};
|
||||
const deactivation = { currentPassword: 'old-password' };
|
||||
|
||||
it('로그인 · 비밀번호 바꾸기 — 칸이 없으면 보지 않는다, 숫자 6자리면 통과', async () => {
|
||||
await expect(through(LoginDto, login)).resolves.toEqual(login);
|
||||
await expect(through(ChangePasswordDto, change)).resolves.toEqual(change);
|
||||
await expect(
|
||||
through(LoginDto, { ...login, otpCode: '012345' }),
|
||||
).resolves.toMatchObject({ otpCode: '012345' });
|
||||
await expect(
|
||||
through(ChangePasswordDto, { ...change, otpCode: '012345' }),
|
||||
).resolves.toMatchObject({ otpCode: '012345' });
|
||||
});
|
||||
|
||||
it('로그인 · 비밀번호 바꾸기 — null 은 400 VAL_001 (IsOptional 은 null 도 건너뛰어 서비스에서 500 이 났다 — 2026-10-31 리뷰)', async () => {
|
||||
await expect(through(LoginDto, { ...login, otpCode: null })).resolves.toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
await expect(
|
||||
through(ChangePasswordDto, { ...change, otpCode: null }),
|
||||
).resolves.toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('숫자 6자리가 아니거나 글이 아니면 400 VAL_001 — 네 본문 모두', async () => {
|
||||
for (const otpCode of ['12345', '1234567', '12 456', 'abcdef', 123456]) {
|
||||
await expect(through(LoginDto, { ...login, otpCode })).resolves.toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
await expect(
|
||||
through(ChangePasswordDto, { ...change, otpCode }),
|
||||
).resolves.toBe('VAL_001');
|
||||
await expect(
|
||||
through(TwoFactorActivationDto, { ...activation, otpCode }),
|
||||
).resolves.toBe('VAL_001');
|
||||
await expect(
|
||||
through(TwoFactorDeactivationDto, { ...deactivation, otpCode }),
|
||||
).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('2단계 켜기 · 끄기 — 코드는 꼭 있어야 한다: 없거나 null 이면 400 VAL_001', async () => {
|
||||
for (const otpCode of [undefined, null]) {
|
||||
await expect(
|
||||
through(TwoFactorActivationDto, { ...activation, otpCode }),
|
||||
).resolves.toBe('VAL_001');
|
||||
await expect(
|
||||
through(TwoFactorDeactivationDto, { ...deactivation, otpCode }),
|
||||
).resolves.toBe('VAL_001');
|
||||
}
|
||||
await expect(
|
||||
through(TwoFactorActivationDto, { ...activation, otpCode: '123456' }),
|
||||
).resolves.toMatchObject({ otpCode: '123456' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { TOTP_CODE_PATTERN } from '@/shared/utils/totp';
|
||||
|
||||
/**
|
||||
* 계정 보안 요청 본문의 공용 칸 검사 (계약 5절) — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 같은 규칙을 쓴다.
|
||||
* 검사는 적은 차례대로 돈다(applyDecorators — 형식 → 길이 → 모양).
|
||||
*/
|
||||
|
||||
/** 인증 앱 코드 — 숫자 6자리 (아니면 400 VAL_001). 붙여 넣은 글의 공백 · `-` 정리는 화면 몫이다 */
|
||||
export function OtpCodeField(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsString({ message: '인증 앱의 코드를 입력해 주세요.' }),
|
||||
Matches(TOTP_CODE_PATTERN, {
|
||||
message: '인증 앱의 코드는 숫자 6자리입니다.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 칸의 인증 앱 코드 (로그인 · 비밀번호 바꾸기) — 보내지 않았으면(undefined) 보지 않고, `null` 이면 400 VAL_001.
|
||||
* `IsOptional` 은 null 도 건너뛰어 서비스까지 가고 코드 확인에서 500 이 났다 (2026-10-31 리뷰)
|
||||
*/
|
||||
export function OptionalOtpCodeField(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ValidateIf((_object: object, value: unknown) => value !== undefined),
|
||||
OtpCodeField(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 지금 비밀번호 — 로그인 비밀번호와 같은 한도 (비지 않고 256자 이하) */
|
||||
export function CurrentPasswordField(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsString({ message: '지금 비밀번호를 입력해 주세요.' }),
|
||||
IsNotEmpty({ message: '지금 비밀번호를 입력해 주세요.' }),
|
||||
MaxLength(MAX_PASSWORD_LENGTH, { message: '지금 비밀번호가 너무 깁니다.' }),
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,35 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import type { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
const strategy = new JwtStrategy({
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
});
|
||||
/** 끊은 세션 기억 대역 */
|
||||
const revokedIds = new Set<string>();
|
||||
const revoked = {
|
||||
isRevoked: (sessionId: string) => revokedIds.has(sessionId),
|
||||
} as unknown as RevokedSessions;
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
{
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
accessTtlSeconds: 3600,
|
||||
refreshTtlSeconds: 1209600,
|
||||
secureCookies: false,
|
||||
},
|
||||
revoked,
|
||||
);
|
||||
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
afterEach(() => revokedIds.clear());
|
||||
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
|
||||
expect(
|
||||
strategy.validate({
|
||||
@@ -34,6 +55,55 @@ describe('JwtStrategy.validate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', () => {
|
||||
expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
}),
|
||||
).toStrictEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
});
|
||||
});
|
||||
|
||||
it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', () => {
|
||||
revokedIds.add('9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a');
|
||||
expect(
|
||||
codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
}),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
// 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다
|
||||
expect(
|
||||
codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: '00000000-0000-4000-8000-000000000000',
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
|
||||
expect(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
/** JWT 의 시각(exp)은 초 단위다 */
|
||||
const MS_PER_SECOND = 1000;
|
||||
@@ -21,11 +22,15 @@ function accessTokenFromCookie(request: Request): string | null {
|
||||
|
||||
/**
|
||||
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양을 확인한다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다
|
||||
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다).
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
constructor(@Inject(AUTH_SETTINGS) settings: AuthSettings) {
|
||||
constructor(
|
||||
@Inject(AUTH_SETTINGS) settings: AuthSettings,
|
||||
private readonly revoked: RevokedSessions,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
|
||||
secretOrKey: settings.jwtSecret,
|
||||
@@ -45,6 +50,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
context: { reason: 'not-an-access-token' },
|
||||
});
|
||||
}
|
||||
const sessionId = typeof payload.sid === 'string' ? payload.sid : null;
|
||||
if (sessionId !== null && this.revoked.isRevoked(sessionId)) {
|
||||
// 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'session-revoked', sid: sessionId },
|
||||
});
|
||||
}
|
||||
return {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
@@ -52,6 +64,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
...(typeof payload.exp === 'number'
|
||||
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
|
||||
: {}),
|
||||
...(sessionId !== null ? { sessionId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LoginAttemptService } from './login-attempt.service';
|
||||
|
||||
/**
|
||||
* 로그인 · 2단계 인증 코드 실패 한도 — 인증 모듈과 계정 보안 모듈(2단계 켜기 · 끄기의 지금 비밀번호 · 코드)이 **같은 카운터**를 쓴다
|
||||
* (계약 5절 "한도 · 규칙"). 한 인스턴스만 있어야 하므로 이 모듈 하나가 내놓는다.
|
||||
*/
|
||||
@Module({
|
||||
providers: [LoginAttemptService],
|
||||
exports: [LoginAttemptService],
|
||||
})
|
||||
export class LoginAttemptModule {}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import { FixedWindowCounter } from './fixed-window-counter';
|
||||
import {
|
||||
@@ -196,6 +199,112 @@ describe('LoginAttemptService', () => {
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('비밀번호는 맞았다(verified — 2단계 인증의 코드 단계, 12단계)', () => {
|
||||
it('차지한 실패를 돌려주되 진행 중 표시는 남긴다 — 코드 단계가 끝날 때까지 같은 주소는 REQ_001', () => {
|
||||
const ticket = service.begin({ email: ARA, ip: IP });
|
||||
ticket.verified();
|
||||
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'REQ_001',
|
||||
reason: 'login-in-progress',
|
||||
}),
|
||||
);
|
||||
ticket.fail();
|
||||
// 실패로 남지 않았다 — 주소 한도만큼 다시 시도할 수 있다
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT; i += 1) {
|
||||
attempt({ email: ARA, ip: `198.51.100.${i}` }, 'fail');
|
||||
}
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toEqual(
|
||||
expect.objectContaining({ reason: 'login-failures-exceeded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('verified 뒤의 succeed · abort 는 자리를 두 번 돌려주지 않는다 — IP 한도가 늘지 않는다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT - 1; i += 1) {
|
||||
attempt({ email: `guess${i}@arasystem.test`, ip: IP }, 'fail');
|
||||
}
|
||||
const ticket = service.begin({ email: ARA, ip: IP });
|
||||
ticket.verified();
|
||||
ticket.succeed();
|
||||
ticket.abort();
|
||||
|
||||
// 실패 19회가 그대로 — 한 번 더 실패하면 한도
|
||||
attempt({ email: 'one-more@arasystem.test', ip: IP }, 'fail');
|
||||
expect(
|
||||
reasonOf(() => service.begin({ email: 'next@arasystem.test', ip: IP })),
|
||||
).toEqual(expect.objectContaining({ scope: 'ip' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('2단계 인증 코드 실패 (beginCode — 12단계)', () => {
|
||||
const code = (ip: string, outcome: 'fail' | 'succeed' | 'abort') =>
|
||||
service.beginCode({ email: ARA, ip })[outcome]();
|
||||
|
||||
it(`같은 주소 15분 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}회를 넘으면 REQ_001 — IP 를 바꿔도, 비밀번호 한도와 따로 센다`, () => {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
code(`198.51.100.${i}`, 'fail');
|
||||
}
|
||||
expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual(
|
||||
{
|
||||
code: 'REQ_001',
|
||||
reason: 'two-factor-code-failures-exceeded',
|
||||
scope: 'address',
|
||||
email: 'a***@arasystem.test',
|
||||
},
|
||||
);
|
||||
// 비밀번호 시도는 그대로 된다
|
||||
expect(reasonOf(() => service.begin({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
// 15분이 지나면 다시 받는다
|
||||
now += TWO_FACTOR_CODE_ADDRESS_WINDOW_MS;
|
||||
expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toBe(
|
||||
'allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it(`24시간에 ${TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT}회 — 15분 창을 넘겨 가며 시도해도 하루 상한에 닿는다`, () => {
|
||||
let failures = 0;
|
||||
while (failures < TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT) {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
code(`198.51.100.${failures}`, 'fail');
|
||||
failures += 1;
|
||||
}
|
||||
now += TWO_FACTOR_CODE_ADDRESS_WINDOW_MS;
|
||||
}
|
||||
expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'two-factor-code-failures-exceeded',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('맞으면(succeed) 차지한 자리만 돌려준다 — 주소의 기록은 지우지 않는다(하루 상한이 늘지 않게)', () => {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT - 1; i += 1) {
|
||||
code(`198.51.100.${i}`, 'fail');
|
||||
}
|
||||
code(IP, 'succeed');
|
||||
code(IP, 'fail');
|
||||
expect(reasonOf(() => service.beginCode({ email: ARA, ip: IP }))).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: 'two-factor-code-failures-exceeded',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 IP 는 로그인 실패 한도에 함께 센다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
service.beginCode({ email: `staff${i}@arasystem.test`, ip: IP }).fail();
|
||||
}
|
||||
expect(
|
||||
reasonOf(() =>
|
||||
service.begin({ email: 'other@arasystem.test', ip: IP }),
|
||||
),
|
||||
).toEqual(expect.objectContaining({ scope: 'ip' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP 묶기 (요청 수 한도와 같은 clientIpKey)', () => {
|
||||
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔 가며 IP 한도를 피하지 못한다', () => {
|
||||
for (let i = 0; i < LOGIN_IP_FAILURE_LIMIT; i += 1) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_WINDOW_MS,
|
||||
TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS,
|
||||
TWO_FACTOR_CODE_ADDRESS_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_WINDOW_MS,
|
||||
} from './auth.constants';
|
||||
import {
|
||||
FixedWindowCounter,
|
||||
@@ -29,6 +33,7 @@ const MAX_TRACKED_KEYS = 100_000;
|
||||
type Scope = 'ip' | 'address';
|
||||
type RejectReason =
|
||||
| 'login-failures-exceeded'
|
||||
| 'two-factor-code-failures-exceeded'
|
||||
| 'login-in-progress'
|
||||
| 'login-tracking-full';
|
||||
|
||||
@@ -48,12 +53,30 @@ export interface LoginAttemptSource {
|
||||
* 진행 중인 로그인 시도 하나. 결과가 정해지면 셋 중 하나를 **한 번** 부른다 (두 번째부터는 무시).
|
||||
*/
|
||||
export interface LoginAttemptTicket {
|
||||
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 */
|
||||
/** 실패(AUTH_004) — 미리 차지한 실패 한 번을 그대로 남긴다 (verified 뒤면 남길 것이 없다 — 진행 중 표시만 끝낸다) */
|
||||
fail(): void;
|
||||
/** 성공 — 그 주소의 실패 기록을 지우고, 차지했던 IP 자리를 돌려준다 (성공은 세지 않는다) */
|
||||
succeed(): void;
|
||||
/** 판단하지 못함(메일 엔진 장애 등) — 차지했던 자리를 모두 돌려준다 */
|
||||
abort(): void;
|
||||
/**
|
||||
* 비밀번호는 맞았다(엔진이 2단계 인증을 요구 — 계약 5절) — 차지한 실패를 돌려주되 **진행 중 표시는 남긴다**.
|
||||
* 코드 단계가 끝날 때까지 같은 주소의 다른 로그인 · 비밀번호 바꾸기는 REQ_001 이다. 끝나면 셋 중 하나를 부른다
|
||||
*/
|
||||
verified(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 2단계 인증 코드 확인 하나 (계약 5절 "한도 · 규칙") — 결과가 정해지면 셋 중 하나를 **한 번** 부른다.
|
||||
* 진행 중 표시는 없다 — 같은 주소의 동시 시도는 비밀번호 시도(LoginAttemptTicket)나 2단계 바꾸기 잠금이 막는다
|
||||
*/
|
||||
export interface TwoFactorCodeTicket {
|
||||
/** 코드 틀림 · 다시 쓴 코드 — 차지한 실패를 그대로 남긴다 */
|
||||
fail(): void;
|
||||
/** 코드가 맞았다 — 차지한 자리를 돌려준다(주소의 기록은 지우지 않는다 — 하루 20회 상한이 늘지 않게) */
|
||||
succeed(): void;
|
||||
/** 판단하지 못함(엔진 장애 등) — 차지한 자리를 돌려준다 */
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +110,17 @@ export class LoginAttemptService {
|
||||
LOGIN_ADDRESS_FAILURE_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
/** 2단계 인증 코드 실패 — 같은 주소 15분 5회 · 24시간 20회 (같은 IP 는 ipFailures 에 함께 센다) */
|
||||
private readonly codeFailures = new FixedWindowCounter(
|
||||
TWO_FACTOR_CODE_ADDRESS_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
private readonly dailyCodeFailures = new FixedWindowCounter(
|
||||
TWO_FACTOR_CODE_ADDRESS_DAY_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_DAY_WINDOW_MS,
|
||||
MAX_TRACKED_KEYS,
|
||||
);
|
||||
/** 엔진 확인이 진행 중인 주소 */
|
||||
private readonly inFlight = new Set<string>();
|
||||
/** 상한이 차서 거절하기 시작했다는 error 로그를 이번 정리 주기에 이미 남겼는가 (로그 폭주 방지) */
|
||||
@@ -120,6 +154,14 @@ export class LoginAttemptService {
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
// 차지한 실패를 이미 돌려줬는가 (verified) — 두 번 돌려주지 않는다
|
||||
let released = false;
|
||||
const releaseReservation = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressSlot !== null) this.addressFailures.release(addressSlot);
|
||||
};
|
||||
const settle = (finish: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -131,14 +173,57 @@ export class LoginAttemptService {
|
||||
fail: () => settle(() => undefined),
|
||||
succeed: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (!released) this.ipFailures.release(ip.slot);
|
||||
released = true;
|
||||
if (addressKey !== null) this.addressFailures.clear(addressKey);
|
||||
}),
|
||||
abort: () =>
|
||||
settle(() => {
|
||||
this.ipFailures.release(ip.slot);
|
||||
if (addressSlot !== null) this.addressFailures.release(addressSlot);
|
||||
}),
|
||||
abort: () => settle(releaseReservation),
|
||||
verified: () => {
|
||||
if (!settled) releaseReservation();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드 확인을 시작한다 — 같은 IP 의 로그인 실패 한도(ipFailures)와 같은 주소의 코드 실패 한도(15분 · 24시간)에
|
||||
* 실패 한 번을 미리 차지한다. 어느 한도든 넘었으면 REQ_001 (차지한 자리는 돌려준다). 이 메서드 안에는 await 가 없다
|
||||
*/
|
||||
beginCode(source: {
|
||||
readonly email: string;
|
||||
readonly ip: string;
|
||||
}): TwoFactorCodeTicket {
|
||||
const now = Date.now();
|
||||
const ipKey = digest(clientIpKey(source.ip));
|
||||
const addressKey = digest(source.email);
|
||||
|
||||
const ip = this.ipFailures.reserve(ipKey, now);
|
||||
if (!ip.ok) throw this.rejected(source, 'ip', this.reasonOf(ip));
|
||||
|
||||
const shortTerm = this.codeFailures.reserve(addressKey, now);
|
||||
if (!shortTerm.ok) {
|
||||
this.ipFailures.release(ip.slot, now);
|
||||
throw this.rejected(source, 'address', this.codeReasonOf(shortTerm));
|
||||
}
|
||||
const daily = this.dailyCodeFailures.reserve(addressKey, now);
|
||||
if (!daily.ok) {
|
||||
this.ipFailures.release(ip.slot, now);
|
||||
this.codeFailures.release(shortTerm.slot, now);
|
||||
throw this.rejected(source, 'address', this.codeReasonOf(daily));
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const settle = (keep: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (keep) return;
|
||||
this.ipFailures.release(ip.slot);
|
||||
this.codeFailures.release(shortTerm.slot);
|
||||
this.dailyCodeFailures.release(daily.slot);
|
||||
};
|
||||
return {
|
||||
fail: () => settle(true),
|
||||
succeed: () => settle(false),
|
||||
abort: () => settle(false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,15 +235,19 @@ export class LoginAttemptService {
|
||||
sweep(now: number = Date.now()): void {
|
||||
const ip = this.ipFailures.sweep(now);
|
||||
const address = this.addressFailures.sweep(now);
|
||||
const code = this.codeFailures.sweep(now);
|
||||
const dailyCode = this.dailyCodeFailures.sweep(now);
|
||||
this.capacityAlarmRaised = false;
|
||||
|
||||
const evicted = ip.evicted + address.evicted;
|
||||
const evicted =
|
||||
ip.evicted + address.evicted + code.evicted + dailyCode.evicted;
|
||||
if (evicted > 0) {
|
||||
this.logger.warn(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿아 잠기지 않은 기록 ${evicted}건을 일찍 지웠습니다 — 많은 IP·주소로 시도하는 공격일 수 있습니다.`,
|
||||
);
|
||||
}
|
||||
const rejected = ip.rejected + address.rejected;
|
||||
const rejected =
|
||||
ip.rejected + address.rejected + code.rejected + dailyCode.rejected;
|
||||
if (rejected > 0) {
|
||||
this.logger.error(
|
||||
`[REQ_001] 로그인 실패 기록이 상한(${MAX_TRACKED_KEYS})에 닿고 밀어낼 기록이 없어 지난 주기에 새 IP·주소의 로그인 ${rejected}건을 거절했습니다 (fail-closed).`,
|
||||
@@ -166,6 +255,15 @@ export class LoginAttemptService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 코드 한도의 거절 까닭 — 상한이 찬 것은 로그인 한도와 같게 다룬다 */
|
||||
private codeReasonOf(
|
||||
result: Extract<ReserveResult, { ok: false }>,
|
||||
): RejectReason {
|
||||
return result.reason === 'limit'
|
||||
? 'two-factor-code-failures-exceeded'
|
||||
: this.reasonOf(result);
|
||||
}
|
||||
|
||||
private reasonOf(
|
||||
result: Extract<ReserveResult, { ok: false }>,
|
||||
): RejectReason {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MoreThan, type Repository } from 'typeorm';
|
||||
|
||||
import { REVOKED_SESSION_MEMORY_MS } from './auth.constants';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z');
|
||||
|
||||
describe('RevokedSessions (끊은 세션 id 기억 — 계약 5절 "로그인된 브라우저")', () => {
|
||||
let repository: { find: jest.Mock };
|
||||
let revoked: RevokedSessions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
repository = { find: jest.fn().mockResolvedValue([]) };
|
||||
revoked = new RevokedSessions(
|
||||
repository as unknown as Repository<AuthSessionEntity>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('넣은 세션은 1시간(액세스 토큰의 가장 긴 수명) 동안 끊긴 것으로 본다', () => {
|
||||
revoked.add(['s1'], new Date(NOW));
|
||||
|
||||
expect(revoked.isRevoked('s1', NOW)).toBe(true);
|
||||
expect(revoked.isRevoked('s2', NOW)).toBe(false);
|
||||
expect(revoked.isRevoked('s1', NOW + REVOKED_SESSION_MEMORY_MS)).toBe(true);
|
||||
expect(revoked.isRevoked('s1', NOW + REVOKED_SESSION_MEMORY_MS + 1)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('서버가 다시 뜨면 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다', async () => {
|
||||
repository.find.mockResolvedValue([
|
||||
{ id: 'recent', revokedAt: new Date(NOW - 60_000) },
|
||||
]);
|
||||
|
||||
await revoked.onApplicationBootstrap();
|
||||
|
||||
expect(repository.find).toHaveBeenCalledWith({
|
||||
select: { id: true, revokedAt: true },
|
||||
where: {
|
||||
revokedAt: MoreThan(new Date(NOW - REVOKED_SESSION_MEMORY_MS)),
|
||||
},
|
||||
order: { revokedAt: 'ASC' },
|
||||
});
|
||||
expect(revoked.isRevoked('recent', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('DB 를 읽지 못하면 기동을 멈춘다 — 끊은 세션의 토큰이 다시 쓰이지 않게', async () => {
|
||||
repository.find.mockRejectedValue(new Error('db down'));
|
||||
await expect(revoked.onApplicationBootstrap()).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('정리 작업은 1시간이 지난 기억만 지운다', () => {
|
||||
revoked.add(['old'], new Date(NOW - REVOKED_SESSION_MEMORY_MS - 1));
|
||||
revoked.add(['fresh'], new Date(NOW));
|
||||
|
||||
revoked.sweep(NOW);
|
||||
|
||||
expect(revoked.size).toBe(1);
|
||||
expect(revoked.isRevoked('fresh', NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { MoreThan, type Repository } from 'typeorm';
|
||||
|
||||
import { REVOKED_SESSION_MEMORY_MS } from './auth.constants';
|
||||
import { AuthSessionEntity } from './auth-session.entity';
|
||||
|
||||
/** 지난 기억을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 끊은 세션 id 기억 (계약 5절 "로그인된 브라우저" — 끊은 세션은 바로 막는다).
|
||||
*
|
||||
* - 액세스 토큰은 DB 를 묻지 않고 검증한다. 대신 토큰에 세션 id(`sid`)를 싣고, 끊은 세션 id 를 여기서 1시간
|
||||
* (액세스 토큰의 가장 긴 수명 — REVOKED_SESSION_MEMORY_MS) 기억해 가드(JwtStrategy)가 401 AUTH_001 로 막는다.
|
||||
* 실시간 알림도 다음 ping 때 그 세션의 연결을 끝낸다 (mail-events.service.ts)
|
||||
* - 세션을 끊는 모든 길(로그아웃 · 비밀번호 바꾸기 · 이미 쓴 리프레시 토큰 · 계정을 쓸 수 없음 · 로그인된 브라우저에서 끊기)은
|
||||
* 세션 표의 조건부 UPDATE 가 끝낸 id 를 여기에 넣는다 (auth-session.repository.ts — 한 곳)
|
||||
* - 서버가 다시 뜨면(onApplicationBootstrap) 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다 — 읽지 못하면 기동을 멈춘다
|
||||
* (끊은 세션의 토큰이 만료까지 다시 쓰이는 일이 없게. 운영은 마이그레이션도 기동 때 DB 에 하므로 DB 없이는 어차피 뜨지 않는다)
|
||||
* - 12단계 전에 발급한 토큰(`sid` 없음)은 막지 못한다 — 만료(최대 1시간)까지 쓰인다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 한 인스턴스에서 끊은 세션을 다른 인스턴스가 모른다(공유 저장소로 옮긴다 — nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class RevokedSessions implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(RevokedSessions.name);
|
||||
/** 세션 id → 끊은 시각(epoch ms) */
|
||||
private readonly revokedAt = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AuthSessionEntity)
|
||||
private readonly sessions: Repository<AuthSessionEntity>,
|
||||
) {}
|
||||
|
||||
/** 기동 — 최근 1시간 안에 끊은 세션을 채운다 (revoked_at 부분 인덱스를 탄다) */
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const since = new Date(Date.now() - REVOKED_SESSION_MEMORY_MS);
|
||||
const rows = await this.sessions.find({
|
||||
select: { id: true, revokedAt: true },
|
||||
where: { revokedAt: MoreThan(since) },
|
||||
order: { revokedAt: 'ASC' },
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime());
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
this.logger.log(
|
||||
`최근 1시간 안에 끊은 세션 ${rows.length}개를 불러왔습니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 끊은 세션들을 기억한다 (세션 표가 끝낸 뒤에만 부른다) */
|
||||
add(sessionIds: readonly string[], revokedAt: Date): void {
|
||||
for (const id of sessionIds) this.remember(id, revokedAt.getTime());
|
||||
}
|
||||
|
||||
/** 1시간 안에 끊은 세션인가 — 가드 · 실시간 알림이 부른다 (DB 를 묻지 않는다) */
|
||||
isRevoked(sessionId: string, now: number = Date.now()): boolean {
|
||||
const at = this.revokedAt.get(sessionId);
|
||||
if (at === undefined) return false;
|
||||
if (now - at > REVOKED_SESSION_MEMORY_MS) {
|
||||
this.revokedAt.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 1시간이 지난 기억을 지운다 — 넣은 차례가 거의 끊은 차례라 앞에서부터 보다가 아직인 것을 만나면 멈춘다 */
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [id, at] of this.revokedAt) {
|
||||
if (now - at <= REVOKED_SESSION_MEMORY_MS) break;
|
||||
this.revokedAt.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.revokedAt.size;
|
||||
}
|
||||
|
||||
private remember(id: string, at: number): void {
|
||||
// 늦게 끊은 시각이 이긴다 — 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다)
|
||||
const previous = this.revokedAt.get(id);
|
||||
if (previous !== undefined && previous >= at) return;
|
||||
this.revokedAt.delete(id);
|
||||
this.revokedAt.set(id, at);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JmapAccountSecurityProvider } from './jmap-account-security.provider';
|
||||
import type { AccountChangeReply, JmapClient } from './jmap.client';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const OTPAUTH =
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.test?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30';
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('JmapAccountSecurityProvider (x:AccountPassword · x:AppPassword — 대리 접근)', () => {
|
||||
let callAccount: jest.Mock;
|
||||
let callAccountChange: jest.Mock;
|
||||
let provider: JmapAccountSecurityProvider;
|
||||
/** 마지막 호출의 메서드 목록 (계정 id 'x1' 로 만든다) */
|
||||
const lastCalls = (mock: jest.Mock): JmapInvocation[] => {
|
||||
const calls = mock.mock.calls as [
|
||||
string,
|
||||
(accountId: string) => JmapInvocation[],
|
||||
][];
|
||||
const [, build] = calls[calls.length - 1];
|
||||
return build('x1');
|
||||
};
|
||||
const answered = (...responses: JmapInvocation[]): AccountChangeReply => ({
|
||||
kind: 'answered',
|
||||
responses,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
callAccount = jest.fn();
|
||||
callAccountChange = jest.fn();
|
||||
provider = new JmapAccountSecurityProvider({
|
||||
callAccount,
|
||||
callAccountChange,
|
||||
} as unknown as JmapClient);
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('2단계 인증', () => {
|
||||
it('켜짐은 비밀번호 객체의 otpAuth.otpUrl 이 있는지로 본다 (가린 값 "****")', async () => {
|
||||
callAccount.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AccountPassword/get',
|
||||
{ list: [{ id: 'singleton', otpAuth: { otpUrl: '****' } }] },
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
await expect(provider.isTwoFactorEnabled(ARA)).resolves.toBe(true);
|
||||
expect(lastCalls(callAccount)).toEqual([
|
||||
['x:AccountPassword/get', { accountId: 'x1', ids: ['singleton'] }, 'g'],
|
||||
]);
|
||||
|
||||
callAccount.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AccountPassword/get',
|
||||
{ list: [{ id: 'singleton', otpAuth: {} }] },
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
await expect(provider.isTwoFactorEnabled(ARA)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('비밀번호 객체를 읽지 못하면 MAIL_001', async () => {
|
||||
callAccount.mockResolvedValueOnce([
|
||||
['error', { type: 'forbidden' }, 'g'],
|
||||
]);
|
||||
expect((await failureOf(provider.isTwoFactorEnabled(ARA)))?.code).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
callAccount.mockResolvedValueOnce([
|
||||
['x:AccountPassword/get', { list: [] }, 'g'],
|
||||
]);
|
||||
expect((await failureOf(provider.isTwoFactorEnabled(ARA)))?.code).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('켜기 — currentSecret + otpAuth.otpUrl, updated → enabled · forbidden → refused · 결과를 모르면 unknown', async () => {
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{ updated: { singleton: null } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe(
|
||||
'enabled',
|
||||
);
|
||||
expect(lastCalls(callAccountChange)).toEqual([
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId: 'x1',
|
||||
update: {
|
||||
singleton: { currentSecret: 'pw', otpAuth: { otpUrl: OTPAUTH } },
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
]);
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
notUpdated: {
|
||||
singleton: {
|
||||
type: 'forbidden',
|
||||
description: 'Current secret is incorrect.',
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe(
|
||||
'refused',
|
||||
);
|
||||
|
||||
callAccountChange.mockResolvedValueOnce({
|
||||
kind: 'unknown',
|
||||
detail: 'timeout',
|
||||
});
|
||||
await expect(provider.enableTwoFactor(ARA, 'pw', OTPAUTH)).resolves.toBe(
|
||||
'unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('끄기 — currentSecret + otpAuth {otpCode, otpUrl: null}, forbidden → code-rejected, 그 밖의 거절은 MAIL_001 (설명 문장은 남기지 않는다)', async () => {
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{ updated: { singleton: null } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
provider.disableTwoFactor(ARA, 'pw', '123456'),
|
||||
).resolves.toBe('disabled');
|
||||
expect(lastCalls(callAccountChange)[0][1]).toEqual({
|
||||
accountId: 'x1',
|
||||
update: {
|
||||
singleton: {
|
||||
currentSecret: 'pw',
|
||||
otpAuth: { otpCode: '123456', otpUrl: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{ notUpdated: { singleton: { type: 'forbidden' } } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
await expect(
|
||||
provider.disableTwoFactor(ARA, 'pw', '000000'),
|
||||
).resolves.toBe('code-rejected');
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
notUpdated: {
|
||||
singleton: { type: 'serverFail', description: 'secret details' },
|
||||
},
|
||||
},
|
||||
's',
|
||||
]),
|
||||
);
|
||||
const failure = await failureOf(
|
||||
provider.disableTwoFactor(ARA, 'pw', '1'),
|
||||
);
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(JSON.stringify(failure?.context)).not.toContain('secret details');
|
||||
});
|
||||
});
|
||||
|
||||
describe('앱 비밀번호', () => {
|
||||
it('목록 — id · 이름 · 만든 때(ISO 8601), 읽지 못하는 항목은 뺀다', async () => {
|
||||
callAccount.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AppPassword/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 'b',
|
||||
description: '회사 PC',
|
||||
createdAt: '2026-10-30T19:54:08Z',
|
||||
secret: '****',
|
||||
},
|
||||
{ id: 'c', description: '휴대폰', createdAt: 'nope' },
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
await expect(provider.listAppPasswords(ARA)).resolves.toEqual([
|
||||
{ id: 'b', name: '회사 PC', createdAt: '2026-10-30T19:54:08.000Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('만들기 — 권한을 메일 프로그램만으로 좁혀(Replace) 만들고, 같은 요청의 목록에서 만든 때를 읽는다 · 값은 그대로 돌려준다', async () => {
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered(
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{
|
||||
created: {
|
||||
k: {
|
||||
id: 'c',
|
||||
secret: 'app_abcdefghijklmnopqrstuvwxyz0123791203',
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
[
|
||||
'x:AppPassword/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 'c',
|
||||
description: '휴대폰',
|
||||
createdAt: '2026-10-31T00:20:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await expect(provider.createAppPassword(ARA, '휴대폰')).resolves.toEqual({
|
||||
id: 'c',
|
||||
name: '휴대폰',
|
||||
createdAt: '2026-10-31T00:20:00.000Z',
|
||||
password: 'app_abcdefghijklmnopqrstuvwxyz0123791203',
|
||||
});
|
||||
expect(lastCalls(callAccountChange)).toEqual([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{
|
||||
accountId: 'x1',
|
||||
create: {
|
||||
k: {
|
||||
description: '휴대폰',
|
||||
permissions: {
|
||||
'@type': 'Replace',
|
||||
permissions: {
|
||||
authenticate: true,
|
||||
imapAuthenticate: true,
|
||||
emailSend: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
[
|
||||
'x:AppPassword/get',
|
||||
{
|
||||
accountId: 'x1',
|
||||
ids: null,
|
||||
properties: ['description', 'createdAt'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('상한(overQuota)은 409 AUTH_009, 결과를 모르면 MAIL_001, 그 밖의 거절도 MAIL_001', async () => {
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered(
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{ notCreated: { k: { type: 'overQuota' } } },
|
||||
's',
|
||||
],
|
||||
['x:AppPassword/get', { list: [] }, 'g'],
|
||||
),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(provider.createAppPassword(ARA, 'x')))?.code,
|
||||
).toBe('AUTH_009');
|
||||
|
||||
callAccountChange.mockResolvedValueOnce({
|
||||
kind: 'unknown',
|
||||
detail: 'timeout',
|
||||
});
|
||||
expect(
|
||||
(await failureOf(provider.createAppPassword(ARA, 'x')))?.code,
|
||||
).toBe('MAIL_001');
|
||||
|
||||
callAccountChange.mockResolvedValueOnce(
|
||||
answered([
|
||||
'x:AppPassword/set',
|
||||
{ notCreated: { k: { type: 'validationFailed' } } },
|
||||
's',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
(await failureOf(provider.createAppPassword(ARA, 'x')))?.code,
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('지우기 — 이미 없는 것(notFound)은 지운 것과 같다, 그 밖의 거절은 MAIL_001', async () => {
|
||||
callAccount.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{ notDestroyed: { b: { type: 'notFound' } } },
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
provider.deleteAppPassword(ARA, 'b'),
|
||||
).resolves.toBeUndefined();
|
||||
expect(lastCalls(callAccount)).toEqual([
|
||||
['x:AppPassword/set', { accountId: 'x1', destroy: ['b'] }, 'd'],
|
||||
]);
|
||||
|
||||
callAccount.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AppPassword/set',
|
||||
{ notDestroyed: { b: { type: 'forbidden' } } },
|
||||
'd',
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
(await failureOf(provider.deleteAppPassword(ARA, 'b')))?.code,
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('모두 지우기 — 목록의 id 를 한 번에(같은 id 를 두 번 넣지 않는다), 없으면 묻지 않는다', async () => {
|
||||
callAccount
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
'x:AppPassword/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 'b',
|
||||
description: 'a',
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
description: 'b',
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['x:AppPassword/set', { destroyed: ['b', 'c'] }, 'd'],
|
||||
]);
|
||||
await expect(provider.deleteAllAppPasswords(ARA)).resolves.toBe(2);
|
||||
expect(lastCalls(callAccount)).toEqual([
|
||||
['x:AppPassword/set', { accountId: 'x1', destroy: ['b', 'c'] }, 'd'],
|
||||
]);
|
||||
|
||||
callAccount.mockReset();
|
||||
callAccount.mockResolvedValueOnce([
|
||||
['x:AppPassword/get', { list: [] }, 'g'],
|
||||
]);
|
||||
await expect(provider.deleteAllAppPasswords(ARA)).resolves.toBe(0);
|
||||
expect(callAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { APP_PASSWORD_PERMISSIONS } from '@/modules/account-security/account-security.constants';
|
||||
import type {
|
||||
AccountSecurityProvider,
|
||||
AppPasswordRecord,
|
||||
CreatedAppPassword,
|
||||
TwoFactorDisableResult,
|
||||
TwoFactorEnableResult,
|
||||
} from '@/modules/account-security/account-security.provider';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { JmapClient, type AccountChangeReply } from './jmap.client';
|
||||
import { findResult, firstSetError } from './jmap-results';
|
||||
import type { JmapError, JmapInvocation, JmapSetResult } from './jmap.types';
|
||||
|
||||
/** 본인 비밀번호 객체 (계정마다 하나 — id singleton) */
|
||||
const PASSWORD_GET = 'x:AccountPassword/get';
|
||||
const PASSWORD_SET = 'x:AccountPassword/set';
|
||||
const PASSWORD_OBJECT_ID = 'singleton';
|
||||
const APP_PASSWORD_GET = 'x:AppPassword/get';
|
||||
const APP_PASSWORD_SET = 'x:AppPassword/set';
|
||||
/** 만들기 요청 안의 이름 */
|
||||
const CREATE_KEY = 'k';
|
||||
|
||||
/** x:AccountPassword/get 의 항목 — otpAuth.otpUrl 은 켜졌으면 "****"(가린 값), 꺼졌으면 없다 */
|
||||
interface JmapAccountPassword {
|
||||
readonly id?: string;
|
||||
readonly otpAuth?: { readonly otpUrl?: unknown } | null;
|
||||
}
|
||||
|
||||
/** x:AppPassword/get 의 항목 — secret 은 늘 "****" (만들 때만 진짜 값) */
|
||||
interface JmapAppPassword {
|
||||
readonly id?: string;
|
||||
readonly description?: unknown;
|
||||
readonly createdAt?: unknown;
|
||||
}
|
||||
|
||||
interface JmapGetResult<T> {
|
||||
readonly list?: T[];
|
||||
}
|
||||
|
||||
function engineFailure(context: Record<string, unknown>): BusinessException {
|
||||
return new BusinessException('MAIL_001', { context });
|
||||
}
|
||||
|
||||
/** 엔진 시각(UTC 초 — "2026-10-30T19:54:08Z") → ISO 8601 (ms) — 읽지 못하면 null */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const time = new Date(value);
|
||||
return Number.isNaN(time.getTime()) ? null : time.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart(JMAP) 계정 보안 어댑터 — AccountSecurityProvider 의 구현 (계약 5절 — 2단계 인증 · 앱 비밀번호).
|
||||
* 모두 게이트웨이 대리 접근으로 계정 관리 기능(`urn:stalwart:jmap`)을 부른다 — 직원이 2단계 인증을 켜면 본인 Basic 은 402 라 쓸 수 없다.
|
||||
* 엔진 동작(2026-10-31 개발 엔진 · 0.16.27 소스 확인):
|
||||
* - 켜기: `x:AccountPassword/set` 에 currentSecret + otpAuth.otpUrl — 엔진은 코드를 보지 않고 저장한다. 지금 비밀번호가 틀리거나
|
||||
* 이미 켜져 있으면(지금 코드를 요구) forbidden
|
||||
* - 끄기: currentSecret + otpAuth {otpCode, otpUrl: null} — 코드 틀림도 비밀번호 틀림과 같은 forbidden "Current secret is incorrect."
|
||||
* - 앱 비밀번호: 값은 엔진이 만들어 만들 때 한 번만 준다(우리가 넣으면 거절), 상한(기본 5)을 넘으면 overQuota, 지운 번호를 다시 쓴다
|
||||
* 엔진 오류의 설명 문장 · 비밀번호 · 코드 · otpauth 주소 · 앱 비밀번호 값 · 이름은 로그 · 오류 context 에 남기지 않는다 (type 만)
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapAccountSecurityProvider implements AccountSecurityProvider {
|
||||
private readonly logger = new Logger(JmapAccountSecurityProvider.name);
|
||||
|
||||
constructor(private readonly jmap: JmapClient) {}
|
||||
|
||||
async isTwoFactorEnabled(user: string): Promise<boolean> {
|
||||
const responses = await this.jmap.callAccount(user, (accountId) => [
|
||||
[PASSWORD_GET, { accountId, ids: [PASSWORD_OBJECT_ID] }, 'g'],
|
||||
]);
|
||||
const result = findResult(responses, 'g', PASSWORD_GET);
|
||||
if (!result.ok) {
|
||||
throw engineFailure({
|
||||
reason: 'password-object-unreadable',
|
||||
jmapType: result.error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
const [item] =
|
||||
(result.args as JmapGetResult<JmapAccountPassword>).list ?? [];
|
||||
if (!item) {
|
||||
// 비밀번호 자격 증명이 없는 계정(외부 디렉터리 등) — 웹메일 계정이 아니다
|
||||
throw engineFailure({ reason: 'password-object-missing' });
|
||||
}
|
||||
const otpUrl = item.otpAuth?.otpUrl;
|
||||
return typeof otpUrl === 'string' && otpUrl !== '';
|
||||
}
|
||||
|
||||
async enableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorEnableResult> {
|
||||
const reply = await this.jmap.callAccountChange(user, (accountId) => [
|
||||
[
|
||||
PASSWORD_SET,
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
[PASSWORD_OBJECT_ID]: {
|
||||
currentSecret: currentPassword,
|
||||
otpAuth: { otpUrl: otpauthUrl },
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
]);
|
||||
const outcome = this.passwordUpdate(user, reply, 'two-factor-enable');
|
||||
if (outcome === 'updated') return 'enabled';
|
||||
return outcome === 'forbidden' ? 'refused' : 'unknown';
|
||||
}
|
||||
|
||||
async disableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorDisableResult> {
|
||||
const reply = await this.jmap.callAccountChange(user, (accountId) => [
|
||||
[
|
||||
PASSWORD_SET,
|
||||
{
|
||||
accountId,
|
||||
update: {
|
||||
[PASSWORD_OBJECT_ID]: {
|
||||
currentSecret: currentPassword,
|
||||
otpAuth: { otpCode, otpUrl: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
]);
|
||||
const outcome = this.passwordUpdate(user, reply, 'two-factor-disable');
|
||||
if (outcome === 'updated') return 'disabled';
|
||||
return outcome === 'forbidden' ? 'code-rejected' : 'unknown';
|
||||
}
|
||||
|
||||
async listAppPasswords(user: string): Promise<AppPasswordRecord[]> {
|
||||
const responses = await this.jmap.callAccount(user, (accountId) => [
|
||||
[
|
||||
APP_PASSWORD_GET,
|
||||
{ accountId, ids: null, properties: ['description', 'createdAt'] },
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
return this.readAppPasswords(responses, 'g');
|
||||
}
|
||||
|
||||
async createAppPassword(
|
||||
user: string,
|
||||
name: string,
|
||||
): Promise<CreatedAppPassword> {
|
||||
const reply = await this.jmap.callAccountChange(user, (accountId) => [
|
||||
[
|
||||
APP_PASSWORD_SET,
|
||||
{
|
||||
accountId,
|
||||
create: {
|
||||
[CREATE_KEY]: {
|
||||
description: name,
|
||||
permissions: {
|
||||
'@type': 'Replace',
|
||||
permissions: { ...APP_PASSWORD_PERMISSIONS },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
// 같은 요청에서 만든 것의 createdAt 을 읽는다 (엔진 상한이 작아 모두 읽어도 가볍다)
|
||||
[
|
||||
APP_PASSWORD_GET,
|
||||
{ accountId, ids: null, properties: ['description', 'createdAt'] },
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
if (reply.kind === 'unknown') {
|
||||
// 엔진이 만들었을 수 있지만 값을 받지 못했다 — 직원은 목록에서 지우고 다시 만든다
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 앱 비밀번호 만들기의 결과를 알 수 없습니다 — 만들어졌다면 값 없이 목록에 남습니다: user=${maskEmail(user)}, 원인=${reply.detail}`,
|
||||
);
|
||||
throw engineFailure({
|
||||
reason: 'app-password-create-outcome-unknown',
|
||||
detail: reply.detail,
|
||||
});
|
||||
}
|
||||
|
||||
const set = findResult(reply.responses, 's', APP_PASSWORD_SET);
|
||||
if (!set.ok) {
|
||||
throw engineFailure({
|
||||
reason: 'app-password-create-failed',
|
||||
jmapType: set.error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
const result = set.args as JmapSetResult;
|
||||
const refusal = firstSetError(result.notCreated);
|
||||
if (refusal) throw this.createRefusal(refusal);
|
||||
|
||||
// 엔진이 이 응답에만 secret 을 싣는다 (createdAt 은 싣지 않는다 — 같은 요청의 get 으로 읽는다)
|
||||
const created = result.created?.[CREATE_KEY];
|
||||
if (
|
||||
!created ||
|
||||
typeof created.id !== 'string' ||
|
||||
typeof created.secret !== 'string' ||
|
||||
created.secret === ''
|
||||
) {
|
||||
throw engineFailure({ reason: 'app-password-create-unanswered' });
|
||||
}
|
||||
// 읽기가 실패해도 값은 이미 만들어졌다 — 만든 때를 모르면 지금으로 둔다 (목록에서 엔진의 값으로 다시 맞춘다)
|
||||
const listed = this.tryReadAppPasswords(reply.responses, 'g').find(
|
||||
(record) => record.id === created.id,
|
||||
);
|
||||
return {
|
||||
id: created.id,
|
||||
name: listed?.name ?? name,
|
||||
createdAt: listed?.createdAt ?? new Date().toISOString(),
|
||||
password: created.secret,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAppPassword(user: string, id: string): Promise<void> {
|
||||
const responses = await this.jmap.callAccount(user, (accountId) => [
|
||||
[APP_PASSWORD_SET, { accountId, destroy: [id] }, 'd'],
|
||||
]);
|
||||
this.assertDestroyed(responses, 'd');
|
||||
}
|
||||
|
||||
async deleteAllAppPasswords(user: string): Promise<number> {
|
||||
const records = await this.listAppPasswords(user);
|
||||
if (records.length === 0) return 0;
|
||||
// 한 요청의 destroy 에 같은 id 를 두 번 넣지 않는다 — 목록의 id 는 서로 다르다
|
||||
const ids = [...new Set(records.map((record) => record.id))];
|
||||
const responses = await this.jmap.callAccount(user, (accountId) => [
|
||||
[APP_PASSWORD_SET, { accountId, destroy: ids }, 'd'],
|
||||
]);
|
||||
return this.assertDestroyed(responses, 'd');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 객체 바꾸기의 해석 — updated · forbidden(엔진이 받지 않음) · unknown(결과를 모름).
|
||||
* 그 밖의 SetError · 메서드 오류는 MAIL_001 (바뀌지 않았다 — type 만 남긴다)
|
||||
*/
|
||||
private passwordUpdate(
|
||||
user: string,
|
||||
reply: AccountChangeReply,
|
||||
call: string,
|
||||
): 'updated' | 'forbidden' | 'unknown' {
|
||||
if (reply.kind === 'unknown') {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기의 결과를 알 수 없습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}, 원인=${reply.detail}`,
|
||||
);
|
||||
return 'unknown';
|
||||
}
|
||||
const set = findResult(reply.responses, 's', PASSWORD_SET);
|
||||
if (!set.ok) {
|
||||
throw engineFailure({
|
||||
reason: `${call}-failed`,
|
||||
jmapType: set.error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
const result = set.args as JmapSetResult;
|
||||
if (
|
||||
result.updated &&
|
||||
Object.prototype.hasOwnProperty.call(result.updated, PASSWORD_OBJECT_ID)
|
||||
) {
|
||||
return 'updated';
|
||||
}
|
||||
const failure =
|
||||
result.notUpdated?.[PASSWORD_OBJECT_ID] ??
|
||||
firstSetError(result.notUpdated);
|
||||
if (failure?.type === 'forbidden') return 'forbidden';
|
||||
if (!failure) {
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 2단계 인증 바꾸기 응답이 결과를 말하지 않습니다 — 엔진을 다시 읽어 맞춥니다: user=${maskEmail(user)}, call=${call}`,
|
||||
);
|
||||
return 'unknown';
|
||||
}
|
||||
throw engineFailure({
|
||||
reason: `${call}-refused`,
|
||||
jmapType: failure.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
/** 만들기 거절 → 코드 (상한 AUTH_009, 그 밖은 MAIL_001 — type 만 남긴다) */
|
||||
private createRefusal(error: JmapError): BusinessException {
|
||||
if (error.type === 'overQuota') {
|
||||
return new BusinessException('AUTH_009', {
|
||||
context: { reason: 'app-password-limit' },
|
||||
});
|
||||
}
|
||||
return engineFailure({
|
||||
reason: 'app-password-create-refused',
|
||||
jmapType: error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
/** 지우기 응답 — 지운 수. 이미 없는 것(notFound)은 지운 것과 같다. 그 밖의 거절 · 메서드 오류는 MAIL_001 */
|
||||
private assertDestroyed(responses: JmapInvocation[], callId: string): number {
|
||||
const set = findResult(responses, callId, APP_PASSWORD_SET);
|
||||
if (!set.ok) {
|
||||
throw engineFailure({
|
||||
reason: 'app-password-delete-failed',
|
||||
jmapType: set.error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
const result = set.args as JmapSetResult;
|
||||
const refusal = Object.values(result.notDestroyed ?? {}).find(
|
||||
(error) => error?.type !== 'notFound',
|
||||
);
|
||||
if (refusal) {
|
||||
throw engineFailure({
|
||||
reason: 'app-password-delete-refused',
|
||||
jmapType: refusal.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
return (result.destroyed ?? []).length;
|
||||
}
|
||||
|
||||
private readAppPasswords(
|
||||
responses: JmapInvocation[],
|
||||
callId: string,
|
||||
): AppPasswordRecord[] {
|
||||
const result = findResult(responses, callId, APP_PASSWORD_GET);
|
||||
if (!result.ok) {
|
||||
throw engineFailure({
|
||||
reason: 'app-passwords-unreadable',
|
||||
jmapType: result.error.type ?? 'unknown',
|
||||
});
|
||||
}
|
||||
return ((result.args as JmapGetResult<JmapAppPassword>).list ?? []).flatMap(
|
||||
(item): AppPasswordRecord[] => {
|
||||
const createdAt = toIso(item.createdAt);
|
||||
if (typeof item.id !== 'string' || createdAt === null) return [];
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
name: typeof item.description === 'string' ? item.description : '',
|
||||
createdAt,
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 만들기와 함께 읽은 목록 — 읽지 못했으면 빈 목록 (값은 이미 만들어졌다) */
|
||||
private tryReadAppPasswords(
|
||||
responses: JmapInvocation[],
|
||||
callId: string,
|
||||
): AppPasswordRecord[] {
|
||||
try {
|
||||
return this.readAppPasswords(responses, callId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MailAccountProfile,
|
||||
@@ -61,6 +62,7 @@ import type {
|
||||
SendHold,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
@@ -807,17 +809,37 @@ export class JmapMailProvider implements MailProvider {
|
||||
private readonly filterRuns: JmapFilterRunner,
|
||||
) {}
|
||||
|
||||
verifyCredentials(email: string, password: string): Promise<string | null> {
|
||||
verifyCredentials(email: string, password: string): Promise<CredentialCheck> {
|
||||
return this.jmap.verifyCredentials(email, password);
|
||||
}
|
||||
|
||||
/** 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword) */
|
||||
/** 엔진의 로그인 요청에 비밀번호 + 코드 (jmap.client.ts 의 verifyTwoFactorLogin — 계약 5절 "로그인") */
|
||||
verifyTwoFactorLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorLoginResult> {
|
||||
return this.jmap.verifyTwoFactorLogin(email, password, otpCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드가 없으면 본인 자격으로만 바꾼다 — 게이트웨이 대리 접근을 쓰지 않는다 (jmap.client.ts 의 changePassword).
|
||||
* 코드가 있으면(2단계 인증을 켠 직원 — 엔진이 본인 자격을 402 로 받지 않는다) 대리 접근으로 코드와 함께 (changePasswordWithCode)
|
||||
*/
|
||||
changePassword(
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
options: { readonly otpCode?: string } = {},
|
||||
): Promise<PasswordChangeOutcome> {
|
||||
return this.jmap.changePassword(email, currentPassword, newPassword);
|
||||
return options.otpCode === undefined
|
||||
? this.jmap.changePassword(email, currentPassword, newPassword)
|
||||
: this.jmap.changePasswordWithCode(
|
||||
email,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
options.otpCode,
|
||||
);
|
||||
}
|
||||
|
||||
async getProfile(
|
||||
|
||||
@@ -109,19 +109,41 @@ describe('JmapClient', () => {
|
||||
|
||||
await expect(
|
||||
client.verifyCredentials(ARA, 'pw:with:colon'),
|
||||
).resolves.toBe(ARA);
|
||||
).resolves.toEqual({ kind: 'valid', account: ARA });
|
||||
|
||||
const { url, authorization } = requestAt(0);
|
||||
expect(url).toBe('http://mail:8080/jmap/session');
|
||||
expect(decodeBasic(authorization)).toBe(`${ARA}:pw:with:colon`);
|
||||
});
|
||||
|
||||
it('401·403 이면 null (틀린 자격 증명)', async () => {
|
||||
it('401·403 이면 틀린 자격 증명(invalid)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toEqual({
|
||||
kind: 'invalid',
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 403));
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toBeNull();
|
||||
await expect(client.verifyCredentials(ARA, 'wrong')).resolves.toEqual({
|
||||
kind: 'invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('402 면 비밀번호는 맞고 2단계 인증 코드가 필요하다(two-factor-required) — 엔진 장애(MAIL_001)로 다루지 않는다 (12단계, 2026-10-31 확인)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json(
|
||||
{
|
||||
type: 'about:blank',
|
||||
title: 'MFA code required',
|
||||
status: 402,
|
||||
detail: 'This account requires multi-factor authentication.',
|
||||
},
|
||||
402,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(client.verifyCredentials(ARA, 'pw')).resolves.toEqual({
|
||||
kind: 'two-factor-required',
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진 5xx·네트워크 오류는 MAIL_001, 429 는 REQ_001', async () => {
|
||||
@@ -271,6 +293,17 @@ describe('JmapClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('2단계 인증을 켠 직원이면 세션 요청이 402 — 바꾸기를 보내지 않고 two-factor-required (12단계 — 코드와 함께 대리 접근으로)', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ title: 'MFA code required' }, 402),
|
||||
);
|
||||
|
||||
await expect(client.changePassword(ARA, CURRENT, NEXT)).resolves.toBe(
|
||||
'two-factor-required',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('들어간 뒤 엔진이 바꾸기를 거절하면(forbidden · 지금 비밀번호 속성 거절 · 그사이 401) current-password-refused — warn 로그에는 가린 주소와 종류만', async () => {
|
||||
const answers: [Response, string][] = [
|
||||
[
|
||||
@@ -437,6 +470,255 @@ describe('JmapClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('2단계 인증 로그인 (verifyTwoFactorLogin — 12단계)', () => {
|
||||
const answer = (type: string) =>
|
||||
json({ type, client_code: 'engine-one-time-code', iss: 'https://mail' });
|
||||
|
||||
it('엔진의 로그인 요청(POST /api/auth)에 비밀번호 · 코드를 싣는다 — 고정 clientId · https redirectUri, 인증 머리글 없음', async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer('authenticated'));
|
||||
|
||||
await expect(
|
||||
client.verifyTwoFactorLogin(ARA, 'pw', '123456'),
|
||||
).resolves.toBe('authenticated');
|
||||
|
||||
const { url, init } = requestAt(0);
|
||||
expect(url).toBe('http://mail:8080/api/auth');
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>).authorization).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
type: 'authCode',
|
||||
accountName: ARA,
|
||||
accountSecret: 'pw',
|
||||
mfaToken: '123456',
|
||||
clientId: 'arasystem-webmail',
|
||||
redirectUri: 'https://arasystem.test/',
|
||||
});
|
||||
});
|
||||
|
||||
it('failure 는 코드 틀림(code-rejected) — 엔진은 비밀번호 틀림과 가르지 않는다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer('failure'));
|
||||
await expect(
|
||||
client.verifyTwoFactorLogin(ARA, 'pw', '000000'),
|
||||
).resolves.toBe('code-rejected');
|
||||
});
|
||||
|
||||
it('코드를 실었는데 mfaRequired · 모르는 type · 4xx(redirectUri 거절 401 등)는 MAIL_001, 429 는 REQ_001 — 응답의 client_code 는 오류 context 에 남기지 않는다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(answer('mfaRequired'));
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.verifyTwoFactorLogin(ARA, 'pw', '123456');
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BusinessException);
|
||||
expect((caught as BusinessException).code).toBe('MAIL_001');
|
||||
expect(
|
||||
JSON.stringify((caught as BusinessException).context),
|
||||
).not.toContain('engine-one-time-code');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 401));
|
||||
expect(await codeOf(client.verifyTwoFactorLogin(ARA, 'pw', '1'))).toBe(
|
||||
'MAIL_001',
|
||||
);
|
||||
fetchMock.mockResolvedValueOnce(json({}, 429));
|
||||
expect(await codeOf(client.verifyTwoFactorLogin(ARA, 'pw', '1'))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('2단계 인증을 켠 직원의 비밀번호 바꾸기 (changePasswordWithCode — 12단계)', () => {
|
||||
/** 대리 접근 세션 — 계정 관리 기능의 계정 id 를 알려 준다 */
|
||||
const accountSession = {
|
||||
...sessionBody,
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'd',
|
||||
'urn:stalwart:jmap': 'x1',
|
||||
},
|
||||
};
|
||||
const passwordAnswer = (result: object) =>
|
||||
json({ methodResponses: [['x:AccountPassword/set', result, 'p']] });
|
||||
|
||||
it('본인 Basic 은 402 라 대리 접근으로 지금 · 새 비밀번호와 코드(otpAuth.otpCode)를 보낸다 — otpUrl 은 보내지 않는다(엔진이 칸별로 합쳐 2단계 인증이 남는다)', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(accountSession))
|
||||
.mockResolvedValueOnce(
|
||||
passwordAnswer({ updated: { singleton: null } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'),
|
||||
).resolves.toBe('changed');
|
||||
|
||||
expect(decodeBasic(requestAt(0).authorization)).toBe(
|
||||
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
|
||||
);
|
||||
const { url, init, authorization } = requestAt(1);
|
||||
expect(url).toBe('http://mail:8080/jmap/');
|
||||
expect(decodeBasic(authorization)).toBe(
|
||||
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [
|
||||
[
|
||||
'x:AccountPassword/set',
|
||||
{
|
||||
accountId: 'x1',
|
||||
update: {
|
||||
singleton: {
|
||||
currentSecret: 'old-pw',
|
||||
secret: 'new-pw-1234',
|
||||
otpAuth: { otpCode: '123456' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'p',
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('forbidden 은 코드 틀림(code-rejected), 새 비밀번호 거절은 new-password-rejected, 5xx · 시간 초과는 outcome-unknown', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(accountSession))
|
||||
.mockResolvedValueOnce(
|
||||
passwordAnswer({
|
||||
notUpdated: {
|
||||
singleton: {
|
||||
type: 'forbidden',
|
||||
description: 'Current secret is incorrect.',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '000000'),
|
||||
).resolves.toBe('code-rejected');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
passwordAnswer({
|
||||
notUpdated: {
|
||||
singleton: { type: 'invalidProperties', properties: ['secret'] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'weak', '123456'),
|
||||
).resolves.toBe('new-password-rejected');
|
||||
|
||||
const logger = Reflect.get(client, 'logger') as {
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||
fetchMock.mockResolvedValueOnce(json({}, 502));
|
||||
await expect(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'),
|
||||
).resolves.toBe('outcome-unknown');
|
||||
});
|
||||
|
||||
it('엔진이 계정 관리 기능을 알려 주지 않으면 세션을 새로 받고, 그래도 없으면 MAIL_001 — 바꾸기를 보내지 않는다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(sessionBody))
|
||||
.mockResolvedValueOnce(json(sessionBody));
|
||||
|
||||
expect(
|
||||
await codeOf(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('계정 관리 호출 (callAccount · callAccountChange — 12단계)', () => {
|
||||
const accountSession = {
|
||||
...sessionBody,
|
||||
primaryAccounts: {
|
||||
'urn:ietf:params:jmap:mail': 'd',
|
||||
'urn:stalwart:jmap': 'x1',
|
||||
},
|
||||
};
|
||||
|
||||
it('대리 접근으로 core · 계정 관리 기능만 선언하고 계정 관리 계정 id 로 부른다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(accountSession))
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:AppPassword/get', { list: [] }, 'g']],
|
||||
}),
|
||||
);
|
||||
|
||||
const responses = await client.callAccount(ARA, (accountId) => [
|
||||
['x:AppPassword/get', { accountId, ids: null }, 'g'],
|
||||
]);
|
||||
|
||||
expect(responses).toEqual([['x:AppPassword/get', { list: [] }, 'g']]);
|
||||
expect(JSON.parse(requestAt(1).init.body as string)).toEqual({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [
|
||||
['x:AppPassword/get', { accountId: 'x1', ids: null }, 'g'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('바꾸는 호출은 응답 전에 끊기거나 5xx 면 unknown — 엔진이 바꿨을 수 있다. 답했으면 answered', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(accountSession))
|
||||
.mockResolvedValueOnce(json({}, 503));
|
||||
await expect(
|
||||
client.callAccountChange(ARA, (accountId) => [
|
||||
['x:AccountPassword/set', { accountId }, 's'],
|
||||
]),
|
||||
).resolves.toEqual({ kind: 'unknown', detail: 'http-503' });
|
||||
|
||||
fetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
await expect(
|
||||
client.callAccountChange(ARA, (accountId) => [
|
||||
['x:AccountPassword/set', { accountId }, 's'],
|
||||
]),
|
||||
).resolves.toEqual({ kind: 'unknown', detail: 'network' });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({ methodResponses: [['x:AccountPassword/set', {}, 's']] }),
|
||||
);
|
||||
await expect(
|
||||
client.callAccountChange(ARA, (accountId) => [
|
||||
['x:AccountPassword/set', { accountId }, 's'],
|
||||
]),
|
||||
).resolves.toEqual({
|
||||
kind: 'answered',
|
||||
responses: [['x:AccountPassword/set', {}, 's']],
|
||||
});
|
||||
});
|
||||
|
||||
it('바뀌지 않은 것이 확실한 실패는 던진다 — 4xx 는 MAIL_001, 429 는 REQ_001', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(accountSession))
|
||||
.mockResolvedValueOnce(
|
||||
json({ type: 'urn:ietf:params:jmap:error:notRequest' }, 400),
|
||||
);
|
||||
expect(
|
||||
await codeOf(
|
||||
client.callAccountChange(ARA, (accountId) => [
|
||||
['x:AccountPassword/set', { accountId }, 's'],
|
||||
]),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 429));
|
||||
expect(
|
||||
await codeOf(
|
||||
client.callAccountChange(ARA, (accountId) => [
|
||||
['x:AccountPassword/set', { accountId }, 's'],
|
||||
]),
|
||||
),
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('대리 접근 세션 (getSession)', () => {
|
||||
it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
@@ -457,6 +739,8 @@ describe('JmapClient', () => {
|
||||
maxAttachmentsBytes: null,
|
||||
eventSourcePath:
|
||||
'/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
|
||||
// 계정 관리 기능(urn:stalwart:jmap)을 알려 주지 않는 응답 — 2단계 인증 · 앱 비밀번호 호출이 MAIL_001 (12단계)
|
||||
managementAccountId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1419,6 +1703,24 @@ describe('비밀번호 바꾸기 응답 해석 (classifyPasswordChange)', () =>
|
||||
});
|
||||
});
|
||||
|
||||
it('코드를 함께 보냈으면(withCode — 12단계) forbidden 은 코드 틀림(code-rejected) — 비밀번호는 바로 앞의 본인 Basic(402)이 맞혔다', () => {
|
||||
const withCode = (error: object) =>
|
||||
classifyPasswordChange(
|
||||
['x:AccountPassword/set', { notUpdated: { singleton: error } }, 'p'],
|
||||
{ withCode: true },
|
||||
);
|
||||
expect(withCode({ type: 'forbidden' })).toEqual({ kind: 'code-rejected' });
|
||||
expect(
|
||||
withCode({ type: 'invalidProperties', properties: ['secret'] }),
|
||||
).toEqual({ kind: 'new-password-rejected' });
|
||||
expect(
|
||||
classifyPasswordChange(
|
||||
['x:AccountPassword/set', { updated: { singleton: null } }, 'p'],
|
||||
{ withCode: true },
|
||||
),
|
||||
).toEqual({ kind: 'changed' });
|
||||
});
|
||||
|
||||
it('그 밖의 SetError · 메서드 오류는 바뀌지 않은 것이 확실한 실패(failed)', () => {
|
||||
expect(notUpdated({ type: 'serverFail' })).toEqual({
|
||||
kind: 'failed',
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type ConfiguredMailSettings,
|
||||
type MailSettings,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import type { PasswordChangeOutcome } from '@/modules/mail/mail.types';
|
||||
import type {
|
||||
CredentialCheck,
|
||||
PasswordChangeOutcome,
|
||||
TwoFactorLoginResult,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type {
|
||||
@@ -84,7 +88,7 @@ const PASSWORD_OBJECT_ID = 'singleton';
|
||||
* - unknown: 응답으로는 바뀌었는지 알 수 없다 — 엔진이 바꿨을 수 있다
|
||||
*/
|
||||
export type PasswordChangeReply =
|
||||
| { readonly kind: 'changed' | 'new-password-rejected' }
|
||||
| { readonly kind: 'changed' | 'new-password-rejected' | 'code-rejected' }
|
||||
| {
|
||||
readonly kind: 'current-password-refused' | 'failed' | 'unknown';
|
||||
readonly detail: string;
|
||||
@@ -98,9 +102,12 @@ export type PasswordChangeReply =
|
||||
* - forbidden — 엔진이 바꾸기를 받지 않았다(지금 비밀번호가 빠지면 이 오류다, 확인함) → current-password-refused.
|
||||
* 세션은 그 비밀번호로 열렸으므로 맞혀 보기가 아니다 — 계정 비밀번호가 아닌 값(앱 비밀번호 등)으로 들어왔거나 변경 권한이 없다
|
||||
* - 그 밖의 SetError · 메서드 오류는 failed (바뀌지 않았다), 응답이 바꾸기 결과를 말하지 않으면 unknown
|
||||
* - `withCode`(2단계 인증 코드를 함께 보냄 — 12단계): forbidden 은 code-rejected. 엔진은 지금 비밀번호 틀림과 코드 틀림을 같은
|
||||
* forbidden "Current secret is incorrect." 로 거절하는데, 비밀번호는 바로 앞에서 본인 자격(402)으로 맞았다 (계약 5절)
|
||||
*/
|
||||
export function classifyPasswordChange(
|
||||
invocation: JmapInvocation | undefined,
|
||||
options: { readonly withCode?: boolean } = {},
|
||||
): PasswordChangeReply {
|
||||
const [name, args] = invocation ?? [];
|
||||
if (name === PASSWORD_SET_METHOD) {
|
||||
@@ -121,7 +128,9 @@ export function classifyPasswordChange(
|
||||
: { kind: 'new-password-rejected' };
|
||||
}
|
||||
if (failure.type === 'forbidden') {
|
||||
return { kind: 'current-password-refused', detail: 'forbidden' };
|
||||
return options.withCode
|
||||
? { kind: 'code-rejected' }
|
||||
: { kind: 'current-password-refused', detail: 'forbidden' };
|
||||
}
|
||||
return { kind: 'failed', detail: `notUpdated/${String(failure.type)}` };
|
||||
}
|
||||
@@ -160,6 +169,20 @@ const EVENT_SOURCE_HEADERS_TIMEOUT_MS = 10_000;
|
||||
/** 변화 소식 스트림의 형식 — 다른 형식으로 답하면(엔진 설정 · 앞단 문제) 엔진 장애로 본다 */
|
||||
const EVENT_STREAM_TYPE = /^text\/event-stream\s*(?:;|$)/i;
|
||||
|
||||
/**
|
||||
* 엔진의 로그인 요청(OAuth `authCode`) — 2단계 인증 코드를 받는 곳은 이것뿐이다(Basic · IMAP · SMTP 에는 코드를 실을 자리가 없다).
|
||||
* 결과는 authenticated · mfaRequired · failure 셋이고, 성공하면 엔진 메모리에 1회용 OAuth 코드가 남는다(10분 — 쓰지 않는다).
|
||||
* redirectUri 는 https 여야 하고(아니면 401), 등록하지 않은 clientId 도 받는다 (2026-10-31 개발 엔진 · 0.16.27 소스 확인 — 계약 5절 "로그인")
|
||||
*/
|
||||
const ENGINE_LOGIN_PATH = '/api/auth';
|
||||
/** 엔진에 알리는 클라이언트 이름 — 고정 이름 (계약 5절) */
|
||||
export const ENGINE_LOGIN_CLIENT_ID = 'arasystem-webmail';
|
||||
|
||||
/** 엔진 로그인 요청의 응답 — type 만 읽는다 (client_code 는 읽지도 남기지도 않는다) */
|
||||
interface EngineLoginReply {
|
||||
readonly type?: unknown;
|
||||
}
|
||||
|
||||
/** 직원별 JMAP 세션 캐시 — 계정 삭제가 늦게 반영되는 상한이기도 하다 */
|
||||
const SESSION_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
@@ -169,6 +192,11 @@ const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals';
|
||||
const DIRECTORY_SESSION_CACHE_KEY = 'mail:jmap-directory-session';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
/**
|
||||
* 비밀번호는 맞고 2단계 인증 코드가 필요하다 — Stalwart 0.16 은 2단계 인증을 켠 계정의 Basic 로그인에 402 "MFA code required" 로 답한다
|
||||
* (WWW-Authenticate 없음, 2026-10-31 개발 엔진에서 확인 — 계약 5절 "로그인")
|
||||
*/
|
||||
const HTTP_PAYMENT_REQUIRED = 402;
|
||||
const HTTP_BAD_REQUEST = 400;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
@@ -192,8 +220,26 @@ export interface JmapSession {
|
||||
* 알려 주지 않으면 null (캐시된 옛 세션에는 없을 수 있다 — openEventSource 가 세션을 다시 받는다)
|
||||
*/
|
||||
readonly eventSourcePath?: string | null;
|
||||
/**
|
||||
* 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)의 계정 id. 알려 주지 않으면 null
|
||||
* (캐시된 옛 세션에는 없을 수 있다 — callAccount 가 세션을 다시 받는다, 12단계)
|
||||
*/
|
||||
readonly managementAccountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 관리 호출(바꾸는 것)의 결과 — 엔진이 답했으면 메서드 응답, 답을 받기 전에 끝났거나(시간 초과 · 연결 끊김) 5xx ·
|
||||
* 해석할 수 없는 본문이면 unknown (엔진이 바꿨을 수 있다). 바뀌지 않은 것이 확실한 실패는 던진다 (callAccountChange)
|
||||
*/
|
||||
export type AccountChangeReply =
|
||||
| { readonly kind: 'answered'; readonly responses: JmapInvocation[] }
|
||||
| { readonly kind: 'unknown'; readonly detail: string };
|
||||
|
||||
/** 계정 관리 기능의 계정 id 가 있는 세션 */
|
||||
type AccountJmapSession = JmapSession & {
|
||||
readonly managementAccountId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다
|
||||
* (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다
|
||||
@@ -352,34 +398,88 @@ export class JmapClient {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 직원 자격 증명을 확인한다.
|
||||
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null. 엔진 장애는 MAIL_001.
|
||||
* 직원 자격 증명을 확인한다 — `<직원 주소>:<비밀번호>` Basic 으로 세션을 조회한다.
|
||||
* 200 이면 맞음(엔진이 아는 정식 계정 주소), 401 · 403 이면 틀림, **402 면 비밀번호는 맞고 2단계 인증 코드가 필요**하다
|
||||
* (계약 5절 — 12단계 전에는 402 를 엔진 장애로 다뤄 엔진 관리 화면에서 OTP 를 켠 직원이 웹메일에 들어오지 못했다).
|
||||
* 엔진 장애는 MAIL_001, 엔진 한도는 REQ_001.
|
||||
*/
|
||||
async verifyCredentials(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string | null> {
|
||||
): Promise<CredentialCheck> {
|
||||
return this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: basicAuthorization(email, password) } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'verify',
|
||||
async (response) => {
|
||||
async (response): Promise<CredentialCheck> => {
|
||||
if (
|
||||
response.status === HTTP_UNAUTHORIZED ||
|
||||
response.status === HTTP_FORBIDDEN
|
||||
) {
|
||||
await discard(response);
|
||||
return null;
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
if (response.status === HTTP_PAYMENT_REQUIRED) {
|
||||
await discard(response);
|
||||
return { kind: 'two-factor-required' };
|
||||
}
|
||||
await this.assertOk(response, 'verify');
|
||||
|
||||
const session = await readJson<JmapSessionResource>(response, 'verify');
|
||||
return session.username?.trim().toLowerCase() || email;
|
||||
return {
|
||||
kind: 'valid',
|
||||
account: session.username?.trim().toLowerCase() || email,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 직원의 로그인 — 엔진의 로그인 요청(`POST /api/auth`, OAuth authCode)에 비밀번호와 코드(mfaToken)를 함께 보낸다
|
||||
* (계약 5절 "로그인"). 엔진은 앱 네트워크 안의 백엔드 요청이라 자동 차단에 세지 않는다 — 코드 실패 한도는 서버가 센다.
|
||||
* - authenticated → authenticated (엔진에 남는 1회용 OAuth 코드는 쓰지 않는다 — 10분 뒤 사라진다)
|
||||
* - failure → code-rejected (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다 — 비밀번호는 바로 앞의 Basic 이 맞혔다)
|
||||
* - mfaRequired(코드를 실었는데 코드를 요구함) · 그 밖의 type · 401(redirectUri 거절 — 설정) · 4xx → MAIL_001, 429 → REQ_001
|
||||
* 비밀번호 · 코드 · 엔진의 client_code 는 어떤 로그 · 오류 context 에도 남기지 않는다
|
||||
*/
|
||||
async verifyTwoFactorLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorLoginResult> {
|
||||
const { domain } = this.requireConfigured();
|
||||
const reply = await this.exchange(
|
||||
ENGINE_LOGIN_PATH,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'authCode',
|
||||
accountName: email,
|
||||
accountSecret: password,
|
||||
mfaToken: otpCode,
|
||||
clientId: ENGINE_LOGIN_CLIENT_ID,
|
||||
redirectUri: `https://${domain}/`,
|
||||
}),
|
||||
},
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'two-factor-login',
|
||||
async (response) => {
|
||||
await this.assertOk(response, 'two-factor-login');
|
||||
return readJson<EngineLoginReply>(response, 'two-factor-login');
|
||||
},
|
||||
);
|
||||
|
||||
if (reply.type === 'authenticated') return 'authenticated';
|
||||
if (reply.type === 'failure') return 'code-rejected';
|
||||
throw engineFailure({
|
||||
reason: 'unexpected-login-reply',
|
||||
call: 'two-factor-login',
|
||||
replyType: typeof reply.type === 'string' ? reply.type : 'missing',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 직원 **본인의 자격으로** 비밀번호를 바꾼다 (계약 1절 "PUT /api/auth/password").
|
||||
* 게이트웨이 대리 접근·관리자 권한으로는 바꾸지 않는다 — 엔진은 본인 자격의 요청만 받는다 (2026-10-28 개발 엔진에서 확인).
|
||||
@@ -413,11 +513,17 @@ export class JmapClient {
|
||||
await discard(response);
|
||||
return null;
|
||||
}
|
||||
// 비밀번호는 맞고 2단계 인증을 켠 직원이다 — 본인 자격으로는 세션이 열리지 않는다 (코드와 함께 changePasswordWithCode 로)
|
||||
if (response.status === HTTP_PAYMENT_REQUIRED) {
|
||||
await discard(response);
|
||||
return 'two-factor-required' as const;
|
||||
}
|
||||
await this.assertOk(response, 'password-session');
|
||||
return readJson<JmapSessionResource>(response, 'password-session');
|
||||
},
|
||||
);
|
||||
if (session === null) return 'current-password-rejected';
|
||||
if (session === 'two-factor-required') return 'two-factor-required';
|
||||
|
||||
const accountId = session.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY];
|
||||
if (!accountId || !session.apiUrl) {
|
||||
@@ -430,7 +536,7 @@ export class JmapClient {
|
||||
|
||||
const reply = await this.sendPasswordChange(
|
||||
toRelativeReference(session.apiUrl),
|
||||
authorization,
|
||||
{ authorization, user: null },
|
||||
JSON.stringify({
|
||||
using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY],
|
||||
methodCalls: [
|
||||
@@ -451,9 +557,60 @@ export class JmapClient {
|
||||
}),
|
||||
);
|
||||
|
||||
return this.passwordChangeOutcome(reply, email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 직원의 비밀번호 바꾸기 (계약 5절 "비밀번호 바꾸기") — 엔진이 본인 자격(Basic 402)을 받지 않으므로
|
||||
* 게이트웨이 대리 접근으로 `x:AccountPassword/set` 에 지금 비밀번호(currentSecret) · 새 비밀번호(secret) · 코드(otpAuth.otpCode)를 보낸다.
|
||||
* otpAuth 는 칸별로 합쳐져 otpUrl 이 그대로 남는다 — 2단계 인증은 켜진 채다 (0.16.27 소스 `registry/mapping/account.rs`).
|
||||
* 엔진은 코드를 확인한 뒤에 새 비밀번호 규칙을 본다 — new-password-rejected 면 코드는 맞았다.
|
||||
* 부르는 쪽이 먼저 비밀번호를 본인 Basic 으로 확인한다(402) — 그래서 forbidden 은 코드 틀림(code-rejected)이다.
|
||||
* 결과 해석은 본인 자격의 바꾸기와 같다 (changed · new-password-rejected · outcome-unknown · 확실한 실패는 던진다)
|
||||
*/
|
||||
async changePasswordWithCode(
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
otpCode: string,
|
||||
): Promise<PasswordChangeOutcome> {
|
||||
const session = await this.accountSession(email);
|
||||
const reply = await this.sendPasswordChange(
|
||||
session.apiPath,
|
||||
{ authorization: this.impersonation(email), user: email },
|
||||
JSON.stringify({
|
||||
using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY],
|
||||
methodCalls: [
|
||||
[
|
||||
PASSWORD_SET_METHOD,
|
||||
{
|
||||
accountId: session.managementAccountId,
|
||||
update: {
|
||||
[PASSWORD_OBJECT_ID]: {
|
||||
currentSecret: currentPassword,
|
||||
secret: newPassword,
|
||||
otpAuth: { otpCode },
|
||||
},
|
||||
},
|
||||
},
|
||||
'p',
|
||||
],
|
||||
],
|
||||
}),
|
||||
{ withCode: true },
|
||||
);
|
||||
return this.passwordChangeOutcome(reply, email);
|
||||
}
|
||||
|
||||
/** 바꾸기 응답의 해석 → 결과 (확실한 실패는 던진다) — 본인 자격 · 대리 접근 두 길이 함께 쓴다 */
|
||||
private passwordChangeOutcome(
|
||||
reply: PasswordChangeReply,
|
||||
email: string,
|
||||
): PasswordChangeOutcome {
|
||||
switch (reply.kind) {
|
||||
case 'changed':
|
||||
case 'new-password-rejected':
|
||||
case 'code-rejected':
|
||||
return reply.kind;
|
||||
case 'current-password-refused':
|
||||
this.logger.warn(
|
||||
@@ -480,9 +637,15 @@ export class JmapClient {
|
||||
*/
|
||||
private async sendPasswordChange(
|
||||
path: string,
|
||||
authorization: string,
|
||||
auth: {
|
||||
readonly authorization: string;
|
||||
/** 대리 접근이면 대상 직원 주소 — 401 · 403 을 다른 대리 접근 호출처럼 해석한다 (본인 자격이면 null) */
|
||||
readonly user: string | null;
|
||||
},
|
||||
body: string,
|
||||
options: { readonly withCode?: boolean } = {},
|
||||
): Promise<PasswordChangeReply> {
|
||||
const { authorization, user } = auth;
|
||||
// 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다
|
||||
const progress = { answered: false };
|
||||
try {
|
||||
@@ -497,8 +660,11 @@ export class JmapClient {
|
||||
'password-change',
|
||||
async (response): Promise<PasswordChangeReply> => {
|
||||
progress.answered = true;
|
||||
// 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다
|
||||
if (response.status === HTTP_UNAUTHORIZED) {
|
||||
if (user !== null) {
|
||||
// 대리 접근 — 401 · 403 은 계정 · 게이트웨이 문제다 (다른 대리 접근 호출과 같은 분류 · 로그)
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
} else if (response.status === HTTP_UNAUTHORIZED) {
|
||||
// 세션을 연 뒤 지금 비밀번호가 다른 곳에서 바뀌었다 — 맞혀 보기가 아니다
|
||||
await discard(response);
|
||||
return { kind: 'current-password-refused', detail: 'http-401' };
|
||||
}
|
||||
@@ -523,7 +689,7 @@ export class JmapClient {
|
||||
} catch {
|
||||
return { kind: 'unknown', detail: 'malformed-response' };
|
||||
}
|
||||
return classifyPasswordChange(parsed.methodResponses?.[0]);
|
||||
return classifyPasswordChange(parsed.methodResponses?.[0], options);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -580,6 +746,125 @@ export class JmapClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)을 대리 접근으로 부른다 (12단계 — 2단계 인증 · 앱 비밀번호).
|
||||
* 계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`(메일 계정 id 와 따로 본다), `using` 은 core · 그 기능뿐이다.
|
||||
* 직원이 2단계 인증을 켰어도 대리 접근은 그대로 된다 (2026-10-31 확인). 메서드 오류는 그대로 돌려주고 해석은 부른 쪽(어댑터)이 한다.
|
||||
* 읽기에 쓴다 — 실패는 모두 던진다(엔진 장애 MAIL_001 · 한도 REQ_001 · 계정 없음 AUTH_001)
|
||||
*/
|
||||
async callAccount(
|
||||
user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
): Promise<JmapInvocation[]> {
|
||||
const session = await this.accountSession(user);
|
||||
return this.exchange(
|
||||
session.apiPath,
|
||||
this.accountRequest(user, build(session.managementAccountId)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'account',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
await this.assertOk(response, 'account');
|
||||
const parsed = await readJson<JmapResponseBody>(response, 'account');
|
||||
if (!Array.isArray(parsed.methodResponses)) {
|
||||
throw engineFailure({
|
||||
reason: 'malformed-response',
|
||||
call: 'account',
|
||||
});
|
||||
}
|
||||
return parsed.methodResponses;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 관리 기능으로 **바꾼다**(2단계 켜기 · 끄기, 앱 비밀번호 만들기) — callAccount 와 같되, 결과를 알 수 없게 끝난 것은
|
||||
* 던지지 않고 unknown 으로 돌려준다(응답 전에 끊김 · 시간 초과 · 5xx · 해석할 수 없는 본문 — 엔진이 바꿨을 수 있다).
|
||||
* 바뀌지 않은 것이 확실한 실패만 던진다: 대리 접근 거절(AUTH_001 · MAIL_001) · 그 밖의 4xx(MAIL_001) · 429(REQ_001)
|
||||
*/
|
||||
async callAccountChange(
|
||||
user: string,
|
||||
build: (accountId: string) => JmapInvocation[],
|
||||
): Promise<AccountChangeReply> {
|
||||
const session = await this.accountSession(user);
|
||||
// 응답 머리를 받았는가 — 받기 전에 끝난 실패(시간 초과 · 연결 끊김)는 엔진에 닿았을 수 있다
|
||||
const progress = { answered: false };
|
||||
try {
|
||||
return await this.exchange(
|
||||
session.apiPath,
|
||||
this.accountRequest(user, build(session.managementAccountId)),
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'account-change',
|
||||
async (response): Promise<AccountChangeReply> => {
|
||||
progress.answered = true;
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
if (response.status >= HTTP_SERVER_ERROR) {
|
||||
await discard(response);
|
||||
return { kind: 'unknown', detail: `http-${response.status}` };
|
||||
}
|
||||
await this.assertOk(response, 'account-change');
|
||||
let parsed: JmapResponseBody;
|
||||
try {
|
||||
parsed = (await response.json()) as JmapResponseBody;
|
||||
} catch {
|
||||
return { kind: 'unknown', detail: 'malformed-response' };
|
||||
}
|
||||
return Array.isArray(parsed.methodResponses)
|
||||
? { kind: 'answered', responses: parsed.methodResponses }
|
||||
: { kind: 'unknown', detail: 'malformed-response' };
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!progress.answered &&
|
||||
error instanceof BusinessException &&
|
||||
error.code === 'MAIL_001'
|
||||
) {
|
||||
// exchange 가 붙인 사유 — timeout · network (로그용 요약)
|
||||
const reason: unknown = error.context?.reason;
|
||||
return {
|
||||
kind: 'unknown',
|
||||
detail: typeof reason === 'string' ? reason : 'no-response',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 계정 관리 호출의 요청 — 대리 접근 자격 · core 와 계정 관리 기능 */
|
||||
private accountRequest(
|
||||
user: string,
|
||||
methodCalls: JmapInvocation[],
|
||||
): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: this.impersonation(user),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: [CORE_CAPABILITY, ACCOUNT_MANAGEMENT_CAPABILITY],
|
||||
methodCalls,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 이 값을 알기 전에 캐시한 세션이면 다시 받는다. 엔진이 알려 주지 않으면 MAIL_001 */
|
||||
private async accountSession(user: string): Promise<AccountJmapSession> {
|
||||
let session = await this.getSession(user);
|
||||
if (!session.managementAccountId) {
|
||||
session = await this.getSession(user, { fresh: true });
|
||||
}
|
||||
const { managementAccountId } = session;
|
||||
if (!managementAccountId) {
|
||||
throw engineFailure({
|
||||
reason: 'account-capability-missing',
|
||||
call: 'account-session',
|
||||
});
|
||||
}
|
||||
return { ...session, managementAccountId };
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 계정에 메일 엔진의 **관리 권한**이 있으면 AUTH_001 — 웹메일로 관리자 계정을 다루지 않는다.
|
||||
* 게이트웨이는 관리자도 대리 접근할 수 있고 그 세션은 관리 권한을 가지므로(확인함), 정적 목록(blockedAccounts)에 없는
|
||||
@@ -1274,6 +1559,9 @@ export class JmapClient {
|
||||
eventSourcePath: eventSourceUrl
|
||||
? toRelativeReference(eventSourceUrl)
|
||||
: null,
|
||||
// 2단계 인증 · 앱 비밀번호만 쓴다 — 없다고 다른 기능까지 막지 않는다 (callAccount 가 MAIL_001)
|
||||
managementAccountId:
|
||||
resource.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY] ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface JmapChangesResult {
|
||||
/** set 의 created 항목 — 엔진이 만든 뒤 알려 주는 속성. id 는 늘 오고, 제출(EmailSubmission)은 보낼 시각 · 상태도 준다 (2026-10-29 확인) */
|
||||
export interface JmapCreatedObject {
|
||||
id?: string;
|
||||
/** x:AppPassword — 엔진이 만든 앱 비밀번호 값 (만들 때 이 응답에만 — 12단계). 로그 · 오류 context 에 넣지 않는다 */
|
||||
secret?: string;
|
||||
/** EmailSubmission — 엔진이 보낼 시각 (붙잡은 제출은 풀리는 시각) */
|
||||
sendAt?: string | null;
|
||||
/** EmailSubmission — pending · final · canceled */
|
||||
|
||||
@@ -120,6 +120,7 @@ export class MailEventsController {
|
||||
|
||||
await this.events.open(user.email, eventStreamSink(res), {
|
||||
tokenExpiresAt: user.accessTokenExpiresAt ?? null,
|
||||
sessionId: user.sessionId ?? null,
|
||||
signal: disconnected.signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type MailEvent,
|
||||
type MailEventSink,
|
||||
} from './mail-events.service';
|
||||
import type { RevokedSessions } from '@/modules/auth/revoked-sessions';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import type {
|
||||
MailLiveState,
|
||||
@@ -129,14 +130,18 @@ async function flush(): Promise<void> {
|
||||
describe('MailEventsService (실시간 알림의 연결 관리)', () => {
|
||||
let fake: ReturnType<typeof fakeProvider>;
|
||||
let service: MailEventsService;
|
||||
/** 끊은 세션 기억 대역 — 테스트가 넣는다 */
|
||||
let revokedIds: Set<string>;
|
||||
|
||||
const connect = (
|
||||
user: string,
|
||||
tokenExpiresAt: number | null = NOW + 3_600_000,
|
||||
sessionId: string | null = null,
|
||||
) => {
|
||||
const connection = client();
|
||||
const opening = service.open(user, connection.sink, {
|
||||
tokenExpiresAt,
|
||||
sessionId,
|
||||
signal: connection.signal,
|
||||
});
|
||||
return { connection, opening };
|
||||
@@ -162,7 +167,14 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
|
||||
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
|
||||
});
|
||||
fake = fakeProvider();
|
||||
service = new MailEventsService(fake.provider);
|
||||
revokedIds = new Set();
|
||||
const revoked = {
|
||||
isRevoked: (sessionId: string) => revokedIds.has(sessionId),
|
||||
};
|
||||
service = new MailEventsService(
|
||||
fake.provider,
|
||||
revoked as unknown as RevokedSessions,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -503,6 +515,42 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
|
||||
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기)의 연결은 다음 ping 때 end 없이 닫힌다 — 다른 세션 · sid 없는 옛 토큰의 연결은 그대로 (계약 5절)', async () => {
|
||||
const { connection: revoked, opening: first } = connect(
|
||||
ARA,
|
||||
undefined,
|
||||
'session-revoked',
|
||||
);
|
||||
await first;
|
||||
const { connection: alive, opening: second } = connect(
|
||||
ARA,
|
||||
undefined,
|
||||
'session-alive',
|
||||
);
|
||||
await second;
|
||||
const legacy = await connected(ARA);
|
||||
|
||||
revokedIds.add('session-revoked');
|
||||
// 다음 ping 전에는 그대로다 (가드가 새 요청을 막고, 열린 연결은 ping 때 본다)
|
||||
expect(revoked.closed).toBe(false);
|
||||
|
||||
await advance(MAIL_EVENTS_PING_MS);
|
||||
expect(revoked.closed).toBe(true);
|
||||
// end 없이 닫는다 — 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다
|
||||
expect(revoked.events.map(({ type }) => type)).toEqual(['state']);
|
||||
for (const connection of [alive, legacy]) {
|
||||
expect(connection.closed).toBe(false);
|
||||
expect(connection.events.at(-1)).toEqual({
|
||||
type: 'ping',
|
||||
data: { upstream: true },
|
||||
});
|
||||
}
|
||||
|
||||
// 닫은 자리는 돌려준다 — 같은 직원이 다시 열 수 있다
|
||||
await connected(ARA, undefined);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('열려 있다가 끊긴 응답은 다음에 보낼 때(소식 · ping) 빠진다 — 그 자리를 다시 쓸 수 있다', async () => {
|
||||
const open = await Promise.all(
|
||||
Array.from({ length: MAX_MAIL_EVENT_CONNECTIONS }, () => connected(ARA)),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { ACCESS_TTL_MAX_SECONDS } from '@/modules/auth/auth.constants';
|
||||
import { RevokedSessions } from '@/modules/auth/revoked-sessions';
|
||||
import {
|
||||
runOutsideRequestContext,
|
||||
traceTag,
|
||||
@@ -70,6 +71,11 @@ export interface MailEventSink {
|
||||
export interface MailEventsOpenOptions {
|
||||
/** 액세스 토큰이 끝나는 시각(epoch ms) — 모르면 null (액세스 토큰의 가장 긴 수명 뒤에 닫는다) */
|
||||
readonly tokenExpiresAt: number | null;
|
||||
/**
|
||||
* 이 연결을 연 로그인 세션 id(액세스 토큰의 sid) — 그 세션을 끊으면 다음 ping 때 연결을 끝낸다 (계약 5절 "로그인된 브라우저").
|
||||
* 12단계 전에 발급한 토큰이면 null (토큰이 끝날 때 닫는다)
|
||||
*/
|
||||
readonly sessionId?: string | null;
|
||||
/** 클라이언트가 끊으면 abort — 기다리는 중이든 연결한 뒤든 바로 정리한다 */
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
@@ -92,6 +98,8 @@ function expiryDelay(tokenExpiresAt: number | null): number {
|
||||
|
||||
interface Client {
|
||||
readonly sink: MailEventSink;
|
||||
/** 이 연결의 로그인 세션 id — 모르면 null */
|
||||
readonly sessionId: string | null;
|
||||
expiryTimer: NodeJS.Timeout | null;
|
||||
removed: boolean;
|
||||
}
|
||||
@@ -125,6 +133,8 @@ interface Hub {
|
||||
* 응답이 이미 끊겼으면(보내기가 false) 그 연결을 빼고 자리를 돌려준다
|
||||
* - 서버 종료(beforeApplicationShutdown — HTTP 서버를 닫기 전)에 모든 연결에 end{server-shutdown} 을 보내고 닫는다 —
|
||||
* 오래 열린 연결이 종료를 붙잡지 않게
|
||||
* - 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기 — 12단계)의 연결은 다음 ping 때 `end` 없이 닫는다 —
|
||||
* 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다 (계정을 더 쓸 수 없을 때와 같은 길 — 계약 2절 "실시간 알림")
|
||||
* - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름)
|
||||
*
|
||||
* ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나),
|
||||
@@ -136,7 +146,10 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
private readonly hubs = new Map<string, Hub>();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(@Inject(MAIL_PROVIDER) private readonly provider: MailProvider) {}
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
private readonly revokedSessions: RevokedSessions,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 연결 하나를 연다 — 처음 state 를 보낸 뒤부터 소식을 보낸다. 돌아온 뒤로 연결은 `signal` 이 끊길 때까지 이어진다.
|
||||
@@ -195,7 +208,12 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
return;
|
||||
}
|
||||
|
||||
const client: Client = { sink, expiryTimer: null, removed: false };
|
||||
const client: Client = {
|
||||
sink,
|
||||
sessionId: options.sessionId ?? null,
|
||||
expiryTimer: null,
|
||||
removed: false,
|
||||
};
|
||||
hub.clients.add(client);
|
||||
this.ensurePing(hub);
|
||||
this.send(hub, client, { type: 'state', data: hub.live });
|
||||
@@ -384,18 +402,28 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
private ensurePing(hub: Hub): void {
|
||||
if (hub.pingTimer) return;
|
||||
hub.pingTimer = runOutsideRequestContext(() =>
|
||||
setInterval(
|
||||
() =>
|
||||
this.broadcast(hub, {
|
||||
type: 'ping',
|
||||
data: { upstream: hub.upstream },
|
||||
}),
|
||||
MAIL_EVENTS_PING_MS,
|
||||
),
|
||||
setInterval(() => this.ping(hub), MAIL_EVENTS_PING_MS),
|
||||
);
|
||||
hub.pingTimer.unref();
|
||||
}
|
||||
|
||||
/** ping 한 번 — 끊은 세션의 연결은 보내지 않고 닫는다(end 없이), 나머지에는 ping */
|
||||
private ping(hub: Hub): void {
|
||||
for (const client of [...hub.clients]) {
|
||||
if (
|
||||
client.sessionId !== null &&
|
||||
this.revokedSessions.isRevoked(client.sessionId)
|
||||
) {
|
||||
this.closeClient(hub, client);
|
||||
continue;
|
||||
}
|
||||
this.send(hub, client, {
|
||||
type: 'ping',
|
||||
data: { upstream: hub.upstream },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */
|
||||
private closeHub(hub: Hub): void {
|
||||
if (hub.closed) return;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/platform-express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
@@ -121,6 +122,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailUserSettingsModule,
|
||||
// 대용량 첨부(11단계) — 공유 링크 · 공개 내려받기(파일 저장 모듈을 함께 내보낸다). 메일 → 공유 → 저장 한 방향
|
||||
FileSharingModule,
|
||||
// 끊은 세션 기억 (12단계) — 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝낸다. 이 모듈은 메일을 가져오지 않는다
|
||||
AuthSessionModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AttachmentContent,
|
||||
BlockedSender,
|
||||
Correspondence,
|
||||
CredentialCheck,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
MailAccountProfile,
|
||||
@@ -39,6 +40,7 @@ import type {
|
||||
SavedDraft,
|
||||
SentMessage,
|
||||
SentMessageRecord,
|
||||
TwoFactorLoginResult,
|
||||
UploadedFile,
|
||||
} from './mail.types';
|
||||
|
||||
@@ -64,21 +66,35 @@ export class SendNotStartedException extends BusinessException {}
|
||||
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* 직원 자격 증명을 메일 엔진에 확인한다. 비밀번호는 저장하지 않는다.
|
||||
* @returns 맞으면 엔진이 아는 정식 계정 주소, 틀리면 null
|
||||
* 직원 자격 증명을 메일 엔진에 확인한다 (CredentialCheck — 맞음 · 틀림 · 2단계 인증 코드 필요). 비밀번호는 저장하지 않는다.
|
||||
* 2단계 인증을 켠 직원은 비밀번호가 맞아도 `two-factor-required` 다 — 코드는 verifyTwoFactorLogin 으로 (계약 5절 "로그인")
|
||||
*/
|
||||
verifyCredentials(email: string, password: string): Promise<string | null>;
|
||||
verifyCredentials(email: string, password: string): Promise<CredentialCheck>;
|
||||
|
||||
/**
|
||||
* 직원 **본인의 자격으로**(지금 비밀번호) 비밀번호를 바꾼다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다 (계약 1절).
|
||||
* 엔진의 판단(틀림 · 거절 · 규칙 위반)과 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고,
|
||||
* 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다 (PasswordChangeOutcome).
|
||||
* 비밀번호는 저장하지 않고 어떤 로그에도 남기지 않는다.
|
||||
* 2단계 인증을 켠 직원의 로그인 — 비밀번호와 인증 앱 코드를 함께 엔진에 확인한다 (계약 5절 "로그인").
|
||||
* 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다) — 막기는 부르는 쪽 몫이다. 비밀번호 · 코드는 로그에 남기지 않는다
|
||||
*/
|
||||
verifyTwoFactorLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorLoginResult>;
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꾼다 (계약 1절 · 5절 "비밀번호 바꾸기"). 엔진의 판단(틀림 · 거절 · 규칙 위반 · 코드 필요 · 코드 틀림)과
|
||||
* 결과를 알 수 없음(바꿨을 수 있다)은 결과로 돌려주고, 결과가 확실한 엔진 장애(바뀌지 않았다)는 MAIL_001(한도는 REQ_001)로 던진다.
|
||||
* - `otpCode` 없음: 직원 **본인의 자격으로**(지금 비밀번호) — 게이트웨이 대리 접근 · 관리자 권한을 쓰지 않는다.
|
||||
* 2단계 인증을 켠 직원이면 바꾸지 않고 `two-factor-required`
|
||||
* - `otpCode` 있음(2단계 인증을 켠 직원 — 비밀번호는 부르는 쪽이 먼저 확인했다): 엔진이 본인 자격(402)을 받지 않아 대리 접근으로
|
||||
* 지금 비밀번호 · 새 비밀번호 · 코드를 보낸다. 2단계 인증은 그대로 남는다
|
||||
* 비밀번호 · 코드는 저장하지 않고 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
changePassword(
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
options?: { readonly otpCode?: string },
|
||||
): Promise<PasswordChangeOutcome>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,7 @@ function fakeProvider(): ProviderMocks {
|
||||
|
||||
return {
|
||||
verifyCredentials: mockOf<'verifyCredentials'>(),
|
||||
verifyTwoFactorLogin: mockOf<'verifyTwoFactorLogin'>(),
|
||||
changePassword: mockOf<'changePassword'>(),
|
||||
getProfile: mockOf<'getProfile'>(),
|
||||
listMailboxes: mockOf<'listMailboxes'>(),
|
||||
|
||||
@@ -371,13 +371,34 @@ export interface MessageSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password").
|
||||
* 직원 자격 확인의 엔진 판단 (계약 1절 "로그인" · 5절 "로그인") — 엔진 장애는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다.
|
||||
* - valid: 맞다 — 엔진이 아는 정식 계정 주소(별칭으로 들어와도 정식 주소)
|
||||
* - invalid: 계정 없음 · 비밀번호 틀림 (구분하지 않는다)
|
||||
* - two-factor-required: 비밀번호는 맞고 엔진이 2단계 인증 코드를 요구한다 (Stalwart 의 HTTP 402 — 2026-10-31 확인).
|
||||
* 코드는 verifyTwoFactorLogin 으로 따로 확인한다
|
||||
*/
|
||||
export type CredentialCheck =
|
||||
| { readonly kind: 'valid'; readonly account: string }
|
||||
| { readonly kind: 'invalid' }
|
||||
| { readonly kind: 'two-factor-required' };
|
||||
|
||||
/**
|
||||
* 2단계 인증 로그인의 엔진 판단 (계약 5절 "로그인") — 비밀번호 + 코드. 엔진 장애는 실패(MAIL_001 · REQ_001)로 던진다.
|
||||
* - authenticated: 둘 다 맞다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 코드 틀림(비밀번호는 바로 앞에서 맞았다. 그사이 바뀐 비밀번호도 엔진은 가르지 않는다)
|
||||
*/
|
||||
export type TwoFactorLoginResult = 'authenticated' | 'code-rejected';
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기의 엔진 판단 (계약 1절 "PUT /api/auth/password" · 5절 "비밀번호 바꾸기").
|
||||
* 결과가 확실한 엔진 장애(바뀌지 않았다)는 결과가 아니라 실패(MAIL_001 · REQ_001)로 던진다.
|
||||
* - changed: 바뀌었다
|
||||
* - current-password-rejected: 지금 비밀번호로 엔진에 들어가지 못했다 (틀림) — 맞혀 보기라 로그인 실패처럼 센다
|
||||
* - current-password-refused: 지금 비밀번호로 들어가기는 했는데 엔진이 바꾸기를 거절했다
|
||||
* (지금 비밀번호 확인 거절 · 변경 권한 없음 · 그사이 다른 곳에서 바뀜) — 맞혀 보기가 아니다
|
||||
* - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 등) — 지금 비밀번호는 맞았다
|
||||
* - new-password-rejected: 새 비밀번호가 엔진 규칙에 어긋난다 (8자 미만 · 강도 등) — 지금 비밀번호(와 코드)는 맞았다
|
||||
* - two-factor-required: 비밀번호는 맞지만 2단계 인증을 켠 직원이라 코드가 없으면 바꾸지 않는다 (코드 없이 보냈을 때)
|
||||
* - code-rejected: 코드를 함께 보냈는데 엔진이 받지 않았다 — 코드 틀림 (12단계)
|
||||
* - outcome-unknown: 바꾸기 요청을 보낸 뒤 결과를 알 수 없게 끝났다 (시간 초과 · 연결 끊김 · 5xx · 해석할 수 없는 응답) — 엔진이 바꿨을 수 있다
|
||||
*/
|
||||
export type PasswordChangeOutcome =
|
||||
@@ -385,6 +406,8 @@ export type PasswordChangeOutcome =
|
||||
| 'current-password-rejected'
|
||||
| 'current-password-refused'
|
||||
| 'new-password-rejected'
|
||||
| 'two-factor-required'
|
||||
| 'code-rejected'
|
||||
| 'outcome-unknown';
|
||||
|
||||
/** 새 메일 확인 (계약 2절 "새 메일 확인") — 메일함이나 메일이 하나라도 바뀌면 달라지는 불투명 값 */
|
||||
|
||||
@@ -21,6 +21,7 @@ const prod = (extra: Record<string, string> = {}) => ({
|
||||
TRUST_PROXY: '2',
|
||||
SHARE_PUBLIC_URL: 'https://files.arasystem.it.kr',
|
||||
CLAMAV_HOST: 'clamav',
|
||||
MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a',
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -398,6 +399,88 @@ describe('validateEnv', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('계정 보안 (12단계)', () => {
|
||||
const dev = (extra: Record<string, string>) => ({
|
||||
NODE_ENV: 'development',
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('비면 기동한다 — 개발은 웹훅 서명 키 없이(그때 웹훅은 모두 401), 앱 네트워크 대역은 기본값', () => {
|
||||
expect(() =>
|
||||
validateEnv(dev({ MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' })),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('웹훅 서명 키는 영문 · 숫자 32자 이상 — 기호 · 공백 · 짧은 값은 환경과 무관하게 실패하고 값은 메시지에 싣지 않는다', () => {
|
||||
for (const value of [
|
||||
'short1234',
|
||||
'has-hyphen-0000000000000000000000',
|
||||
'has#hash00000000000000000000000000',
|
||||
'has space 0000000000000000000000000',
|
||||
]) {
|
||||
try {
|
||||
validateEnv(dev({ MAIL_WEBHOOK_SECRET: value }));
|
||||
fail('예외가 발생해야 한다');
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
expect(message).toMatch(
|
||||
/MAIL_WEBHOOK_SECRET 은 영문 · 숫자 32자 이상/,
|
||||
);
|
||||
expect(message).not.toContain(value);
|
||||
}
|
||||
}
|
||||
expect(() =>
|
||||
validateEnv(dev({ MAIL_WEBHOOK_SECRET: 'a'.repeat(32) })),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('앱 네트워크 대역은 주소/접두 길이 — 아니면 실패한다', () => {
|
||||
for (const value of [
|
||||
'172.16.71.0',
|
||||
'172.16.71.0/33',
|
||||
'app-network',
|
||||
'172.16.71.0/024',
|
||||
]) {
|
||||
expect(() => validateEnv(dev({ APP_NETWORK_SUBNET: value }))).toThrow(
|
||||
/APP_NETWORK_SUBNET 은 대역/,
|
||||
);
|
||||
}
|
||||
for (const value of ['172.16.71.0/24', '10.10.0.0/16', 'fd00::/64']) {
|
||||
expect(() =>
|
||||
validateEnv(dev({ APP_NETWORK_SUBNET: value })),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('운영은 웹훅 서명 키가 필수다', () => {
|
||||
expect(() => validateEnv(omit(prod(), 'MAIL_WEBHOOK_SECRET'))).toThrow(
|
||||
/MAIL_WEBHOOK_SECRET 가 설정되지 않았습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it('운영은 예시 파일의 서명 키를 거부한다 — 하이픈 없는 changeme 도 대소문자 없이 (개발 · 운영 예시 값 그대로)', () => {
|
||||
for (const placeholder of [
|
||||
'changemeinlocalenv00000000000000',
|
||||
'CHANGEMEBEFOREDEPLOY000000000000',
|
||||
]) {
|
||||
// 형식(영문 · 숫자 32자 이상)은 맞는다 — 예시 값 검사가 따로 막는다
|
||||
expect(() =>
|
||||
validateEnv(dev({ MAIL_WEBHOOK_SECRET: placeholder })),
|
||||
).not.toThrow();
|
||||
try {
|
||||
validateEnv(prod({ MAIL_WEBHOOK_SECRET: placeholder }));
|
||||
fail('예외가 발생해야 한다');
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
expect(message).toMatch(
|
||||
/MAIL_WEBHOOK_SECRET 가 예시 값 그대로입니다/,
|
||||
);
|
||||
expect(message).not.toContain(placeholder);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('대용량 첨부 (11단계)', () => {
|
||||
const dev = (extra: Record<string, string>) => ({
|
||||
NODE_ENV: 'development',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DEFAULT_REFRESH_TTL_SECONDS,
|
||||
REFRESH_TTL_MAX_SECONDS,
|
||||
} from '@/modules/auth/auth.constants';
|
||||
import { MAIL_WEBHOOK_SECRET_PATTERN } from '@/modules/account-security/account-security.constants';
|
||||
import { isLocalPart } from '@/modules/auth/employee-address';
|
||||
import { SHARE_DOWNLOAD_LIMIT_MAX } from '@/modules/file-sharing/file-sharing.settings';
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
DEFAULT_MAIL_DIRECTORY_LOCAL_PART,
|
||||
parseAccountList,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import { parseCidr } from '@/shared/utils/cidr';
|
||||
import { parseDurationSeconds } from '@/shared/utils/duration';
|
||||
import { isPositiveIntString } from '@/shared/utils/env';
|
||||
import {
|
||||
@@ -66,6 +68,8 @@ const REQUIRED_IN_PRODUCTION = [
|
||||
'SHARE_PUBLIC_URL',
|
||||
// 대용량 첨부의 악성 검사기(clamd) — 운영은 검사 없이 받을 수 있게 되는 일이 없게 (계약 4절 "악성 검사")
|
||||
'CLAMAV_HOST',
|
||||
// 엔진 웹훅의 서명 키(12단계) — 메일 프로그램의 로그인 기록 · 앱 비밀번호의 마지막 사용 (계약 5절 "환경 변수")
|
||||
'MAIL_WEBHOOK_SECRET',
|
||||
] as const;
|
||||
|
||||
/** 값이 있다면 양의 정수(앞자리 0 없이)여야 하는 키 — shared/utils/env.ts 의 isPositiveIntString */
|
||||
@@ -114,6 +118,8 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
|
||||
'JWT_SECRET',
|
||||
// 전 직원 메일함의 열쇠다 (mail/README.md "보안")
|
||||
'MAIL_GATEWAY_PASSWORD',
|
||||
// 엔진 웹훅의 서명 키 — 이것을 알면 로그인 기록을 꾸밀 수 있다 (12단계)
|
||||
'MAIL_WEBHOOK_SECRET',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -451,6 +457,31 @@ function validateLargeFileSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 보안 설정 (12단계 — 계약 5절 "환경 변수") — 값이 있을 때 형식을 본다(모든 환경). 운영 필수 · 예시 값 거부는 아래 공통 검사가 한다.
|
||||
* - MAIL_WEBHOOK_SECRET: 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자르므로 기호를 받지 않는다 — setup 과 같은 값을 읽는다).
|
||||
* 비우면(개발) 엔진 웹훅을 모두 401 로 거절한다
|
||||
* - APP_NETWORK_SUBNET: 대역(주소/접두 길이) — 엔진 웹훅의 소켓 주소를 이 안에서만 받는다. 비우면 compose 기본값 172.16.71.0/24
|
||||
* 값 자체(서명 키)는 오류 메시지에 싣지 않는다
|
||||
*/
|
||||
function validateAccountSecuritySettings(
|
||||
config: Record<string, unknown>,
|
||||
errors: string[],
|
||||
): void {
|
||||
const secret = readString(config, 'MAIL_WEBHOOK_SECRET');
|
||||
if (secret !== undefined && !MAIL_WEBHOOK_SECRET_PATTERN.test(secret)) {
|
||||
errors.push(
|
||||
`MAIL_WEBHOOK_SECRET 은 영문 · 숫자 32자 이상이어야 합니다 — 기호 · 공백 없이. (현재 ${secret.length}자)`,
|
||||
);
|
||||
}
|
||||
const subnet = readString(config, 'APP_NETWORK_SUBNET');
|
||||
if (subnet !== undefined && parseCidr(subnet) === null) {
|
||||
errors.push(
|
||||
`APP_NETWORK_SUBNET 은 대역(주소/접두 길이)이어야 합니다. (예: 172.16.71.0/24, 현재 값: "${subnet}")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */
|
||||
const DOMAIN_PATTERN =
|
||||
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
||||
@@ -620,6 +651,9 @@ export function validateEnv(
|
||||
// 1-5. 대용량 첨부 (11단계)
|
||||
validateLargeFileSettings(config, isProduction, errors);
|
||||
|
||||
// 1-6. 계정 보안 (12단계)
|
||||
validateAccountSecuritySettings(config, errors);
|
||||
|
||||
if (isProduction) {
|
||||
// 2. 운영 필수값 — 없으면 기동 실패
|
||||
for (const key of REQUIRED_IN_PRODUCTION) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createCidrMatcher, parseCidr } from './cidr';
|
||||
|
||||
describe('parseCidr', () => {
|
||||
it('주소/접두 길이를 읽는다 (IPv4 · IPv6)', () => {
|
||||
expect(parseCidr('172.16.71.0/24')).toEqual({
|
||||
address: '172.16.71.0',
|
||||
prefix: 24,
|
||||
family: 4,
|
||||
});
|
||||
expect(parseCidr(' fd00::/64 ')).toEqual({
|
||||
address: 'fd00::',
|
||||
prefix: 64,
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'172.16.71.0',
|
||||
'172.16.71.0/',
|
||||
'172.16.71.0/33',
|
||||
'172.16.71.0/024',
|
||||
'172.16.71/24',
|
||||
'fd00::/129',
|
||||
'mail/24',
|
||||
'172.16.71.0/24/8',
|
||||
'',
|
||||
])('대역이 아니면 null — %j', (value) => {
|
||||
expect(parseCidr(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCidrMatcher', () => {
|
||||
const inAppNetwork = createCidrMatcher(parseCidr('172.16.71.0/24')!);
|
||||
|
||||
it('대역 안의 주소만 true — IPv4 를 담은 IPv6 표기(소켓 주소)도 같은 주소로 본다', () => {
|
||||
expect(inAppNetwork('172.16.71.2')).toBe(true);
|
||||
expect(inAppNetwork('::ffff:172.16.71.2')).toBe(true);
|
||||
expect(inAppNetwork('::FFFF:172.16.71.254')).toBe(true);
|
||||
expect(inAppNetwork('172.16.72.2')).toBe(false);
|
||||
expect(inAppNetwork('127.0.0.1')).toBe(false);
|
||||
expect(inAppNetwork('::1')).toBe(false);
|
||||
});
|
||||
|
||||
it('주소가 아니면 false', () => {
|
||||
expect(inAppNetwork(undefined)).toBe(false);
|
||||
expect(inAppNetwork(null)).toBe(false);
|
||||
expect(inAppNetwork('')).toBe(false);
|
||||
expect(inAppNetwork('mail')).toBe(false);
|
||||
});
|
||||
|
||||
it('IPv6 대역', () => {
|
||||
const inV6 = createCidrMatcher(parseCidr('fd00:1::/64')!);
|
||||
expect(inV6('fd00:1::5')).toBe(true);
|
||||
expect(inV6('fd00:2::5')).toBe(false);
|
||||
expect(inV6('172.16.71.2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BlockList, isIP, isIPv4 } from 'net';
|
||||
|
||||
/**
|
||||
* 대역(CIDR) 해석 · 들어 있는지 보기 (순수 함수) — 엔진 웹훅의 접속 주소가 앱 네트워크(`APP_NETWORK_SUBNET`)인지 본다 (계약 5절 "엔진 웹훅 받기"),
|
||||
* env 검증도 같은 해석을 쓴다.
|
||||
*/
|
||||
|
||||
export interface Cidr {
|
||||
readonly address: string;
|
||||
readonly prefix: number;
|
||||
readonly family: 4 | 6;
|
||||
}
|
||||
|
||||
/** 접두 길이 — 앞자리 0 없이 (env 숫자 규칙과 같다) */
|
||||
const PREFIX_PATTERN = /^(?:0|[1-9]\d{0,2})$/;
|
||||
const IPV4_MAX_PREFIX = 32;
|
||||
const IPV6_MAX_PREFIX = 128;
|
||||
/** IPv4 를 담은 IPv6 표기 — `::ffff:172.16.71.3` (소켓 주소가 이렇게 올 수 있다) */
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:';
|
||||
|
||||
/** `주소/접두 길이` 하나 — 아니면 null (접두 길이를 빼면 대역이 아니다) */
|
||||
export function parseCidr(value: string): Cidr | null {
|
||||
const parts = value.trim().split('/');
|
||||
if (parts.length !== 2) return null;
|
||||
const [address, prefixText] = parts;
|
||||
const family = isIP(address);
|
||||
if (family === 0 || !PREFIX_PATTERN.test(prefixText)) return null;
|
||||
const prefix = Number(prefixText);
|
||||
const max = family === 4 ? IPV4_MAX_PREFIX : IPV6_MAX_PREFIX;
|
||||
return prefix <= max
|
||||
? { address, prefix, family: family === 4 ? 4 : 6 }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 견줄 주소 — 앞뒤 공백 · 영역 표시(`%eth0`)를 떼고, IPv4 를 담은 IPv6 는 IPv4 로 */
|
||||
function comparableAddress(address: string): string {
|
||||
const trimmed = address.trim().split('%')[0];
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith(IPV4_MAPPED_PREFIX)) {
|
||||
const ipv4 = trimmed.slice(IPV4_MAPPED_PREFIX.length);
|
||||
if (isIPv4(ipv4)) return ipv4;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 대역에 든 주소인지 보는 함수 — 주소가 아니거나(빈 값 · undefined) 다른 갈래(IPv4 대역에 IPv6)면 false */
|
||||
export function createCidrMatcher(
|
||||
cidr: Cidr,
|
||||
): (address: string | null | undefined) => boolean {
|
||||
const list = new BlockList();
|
||||
list.addSubnet(
|
||||
cidr.address,
|
||||
cidr.prefix,
|
||||
cidr.family === 4 ? 'ipv4' : 'ipv6',
|
||||
);
|
||||
return (address) => {
|
||||
if (!address) return false;
|
||||
const candidate = comparableAddress(address);
|
||||
const family = isIP(candidate);
|
||||
if (family === 0) return false;
|
||||
return list.check(candidate, family === 4 ? 'ipv4' : 'ipv6');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { hasForbiddenRequestTargetChar } from './request-target';
|
||||
|
||||
describe('hasForbiddenRequestTargetChar (요청 대상 원문 — 보이는 ASCII 중 \\ · # 을 뺀 것만)', () => {
|
||||
it.each([
|
||||
['/api/health'],
|
||||
['/api/messages?q=%ED%95%9C%EA%B8%80&limit=20'],
|
||||
['/api/%ED%95%9C'],
|
||||
['/api/mail-blocks/kim%40spam.example'],
|
||||
['/api/contacts?cursor=eyJ2IjoxfQ%3D%3D'],
|
||||
['/'],
|
||||
['*'],
|
||||
['http://portal.example/api/health'],
|
||||
])('%s 는 통과', (target) => {
|
||||
expect(hasForbiddenRequestTargetChar(target)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['날 \\', '/api/internal\\mail-engine-events'],
|
||||
['# (조각)', '/api/health#a'],
|
||||
['\\ 와 #', '/api/health\\#a'],
|
||||
['절대 주소 꼴의 \\', 'http://x/api/internal\\mail-engine-events'],
|
||||
['0xA0', '/api/a b'],
|
||||
['0x80', '/api/a\u0080b'],
|
||||
['0xFF', '/api/aÿb'],
|
||||
['BOM(0xFEFF)', '/api/ab'],
|
||||
['NUL', '/api/a\u0000b'],
|
||||
['탭', '/api/a\tb'],
|
||||
['줄바꿈', '/api/a\nb'],
|
||||
['공백', '/api/a b'],
|
||||
['DEL(0x7F)', '/api/a\u007fb'],
|
||||
['한글 날 글자', '/api/한'],
|
||||
])('%s 가 있으면 막는다', (_label, target) => {
|
||||
expect(hasForbiddenRequestTargetChar(target)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 요청 대상(request-target) 원문 검사 (순수 함수) — 받는 글자는 보이는 ASCII(0x21–0x7E) 중 `\` · `#` 을 뺀 것뿐이다.
|
||||
*
|
||||
* Express 의 parseurl 은 대상에 `#` · 공백류 · 0xA0 같은 글자가 있거나 `/` 로 시작하지 않으면(절대 주소 꼴) 옛 `url.parse` 로 다시 읽는데,
|
||||
* 그때 `\` 를 `/` 로 바꾼다 — 앞단 nginx 가 정규식으로 닫은 경로가 백엔드에서는 열렸다
|
||||
* (2026-10-31 인프라 리뷰: `POST /api/internal\mail-engine-events#x` 가 웹훅 가드까지 닿음, `/api/health\#a` 도 같다).
|
||||
* 브라우저 · HTTP 클라이언트는 이런 글자를 퍼센트 인코딩해 보낸다(한글은 `%ED%95%9C` 꼴로 들어와 통과한다).
|
||||
* 지금 Node 는 제어 문자 · 0x80 이상 바이트가 든 대상을 HTTP 파서에서 먼저 거절하지만(HPE_INVALID_URL), 파서가 느슨해져도 여기서 막는다.
|
||||
* 길이에 비례해 한 번 훑는다.
|
||||
*/
|
||||
|
||||
const FIRST_VISIBLE_ASCII = 0x21;
|
||||
const LAST_VISIBLE_ASCII = 0x7e;
|
||||
const BACKSLASH = 0x5c;
|
||||
const HASH = 0x23;
|
||||
|
||||
/** 요청 대상에 받지 않는 글자(`\` · `#` · 제어 문자 · 공백 · 0x7F · 0x80 이상)가 있는가 */
|
||||
export function hasForbiddenRequestTargetChar(target: string): boolean {
|
||||
for (let index = 0; index < target.length; index += 1) {
|
||||
const code = target.charCodeAt(index);
|
||||
if (
|
||||
code < FIRST_VISIBLE_ASCII ||
|
||||
code > LAST_VISIBLE_ASCII ||
|
||||
code === BACKSLASH ||
|
||||
code === HASH
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
TOTP_PERIOD_SECONDS,
|
||||
buildOtpauthUrl,
|
||||
encodeBase32,
|
||||
totpCode,
|
||||
totpCounterAt,
|
||||
verifyTotp,
|
||||
} from './totp';
|
||||
|
||||
/** RFC 6238 부록 B 의 SHA-1 비밀 ("12345678901234567890") */
|
||||
const RFC_SECRET = Buffer.from('12345678901234567890', 'ascii');
|
||||
|
||||
describe('encodeBase32 (RFC 4648, 채움 없음)', () => {
|
||||
it.each([
|
||||
['', ''],
|
||||
['f', 'MY'],
|
||||
['fo', 'MZXQ'],
|
||||
['foo', 'MZXW6'],
|
||||
['foob', 'MZXW6YQ'],
|
||||
['fooba', 'MZXW6YTB'],
|
||||
['foobar', 'MZXW6YTBOI'],
|
||||
])('RFC 4648 표본 %j → %s', (input, expected) => {
|
||||
expect(encodeBase32(Buffer.from(input, 'ascii'))).toBe(expected);
|
||||
});
|
||||
|
||||
it('160비트(20바이트)는 32자 — 2단계 인증 등록의 비밀 길이', () => {
|
||||
const encoded = encodeBase32(Buffer.alloc(20, 0xff));
|
||||
expect(encoded).toHaveLength(32);
|
||||
expect(encoded).toMatch(/^[A-Z2-7]+$/);
|
||||
expect(encodeBase32(Buffer.alloc(20))).toBe('A'.repeat(32));
|
||||
});
|
||||
});
|
||||
|
||||
describe('totpCode (RFC 6238 SHA-1 · 6자리)', () => {
|
||||
// 부록 B 의 8자리 값의 아래 6자리 — 같은 HMAC 을 10^6 으로 나눈 나머지다
|
||||
it.each([
|
||||
[59, '287082'],
|
||||
[1111111109, '081804'],
|
||||
[1111111111, '050471'],
|
||||
[1234567890, '005924'],
|
||||
[2000000000, '279037'],
|
||||
[20000000000, '353130'],
|
||||
])('T=%d 초 → %s', (seconds, expected) => {
|
||||
expect(totpCode(RFC_SECRET, totpCounterAt(seconds * 1000))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTotp', () => {
|
||||
const at = 1_800_000_000_000; // 30초 단계 안의 어느 순간
|
||||
const counter = totpCounterAt(at);
|
||||
|
||||
it('지금 단계와 앞뒤 한 단계(±30초)의 코드를 받고, 맞은 단계 번호를 돌려준다', () => {
|
||||
expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter), at)).toBe(
|
||||
counter,
|
||||
);
|
||||
expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter - 1), at)).toBe(
|
||||
counter - 1,
|
||||
);
|
||||
expect(verifyTotp(RFC_SECRET, totpCode(RFC_SECRET, counter + 1), at)).toBe(
|
||||
counter + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('두 단계 이상 떨어진 코드 · 다른 비밀의 코드는 받지 않는다 (엔진과 같은 창)', () => {
|
||||
for (const offset of [-3, -2, 2, 3]) {
|
||||
const code = totpCode(RFC_SECRET, counter + offset);
|
||||
// 우연히 창 안의 코드와 같은 값이면 건너뛴다 (10^6 분의 3)
|
||||
const inWindow = [-1, 0, 1].some(
|
||||
(step) => totpCode(RFC_SECRET, counter + step) === code,
|
||||
);
|
||||
if (!inWindow) expect(verifyTotp(RFC_SECRET, code, at)).toBeNull();
|
||||
}
|
||||
const other = Buffer.from('another-secret-20byt', 'ascii');
|
||||
const foreign = totpCode(other, counter);
|
||||
if (foreign !== totpCode(RFC_SECRET, counter)) {
|
||||
expect(verifyTotp(RFC_SECRET, foreign, at)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('숫자 6자리가 아니면 견주지 않고 null', () => {
|
||||
const code = totpCode(RFC_SECRET, counter);
|
||||
for (const bad of [
|
||||
'',
|
||||
'12345',
|
||||
'1234567',
|
||||
` ${code}`,
|
||||
'abcdef',
|
||||
'12 456',
|
||||
]) {
|
||||
expect(verifyTotp(RFC_SECRET, bad, at)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('단계 경계 바로 앞뒤에서도 앞뒤 한 단계를 받는다', () => {
|
||||
const boundary = (counter + 1) * TOTP_PERIOD_SECONDS * 1000;
|
||||
const before = totpCode(RFC_SECRET, counter);
|
||||
expect(verifyTotp(RFC_SECRET, before, boundary - 1)).toBe(counter);
|
||||
expect(verifyTotp(RFC_SECRET, before, boundary)).toBe(counter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOtpauthUrl', () => {
|
||||
it('계약 5절의 모양 — 라벨은 「발급자:주소」(각각 인코딩, 사이 콜론은 그대로) · SHA1 · 6자리 · 30초', () => {
|
||||
expect(
|
||||
buildOtpauthUrl({
|
||||
issuer: 'ARA SYSTEM',
|
||||
account: 'ara.kim@arasystem.it.kr',
|
||||
secret: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP',
|
||||
}),
|
||||
).toBe(
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30',
|
||||
);
|
||||
});
|
||||
|
||||
it('URL 로 다시 읽으면 발급자 · 주소 · 값이 그대로 나온다 (엔진의 totp-rs from_url 이 읽는 방식)', () => {
|
||||
const url = new URL(
|
||||
buildOtpauthUrl({
|
||||
issuer: 'ARA SYSTEM',
|
||||
account: 'doyun.lee@arasystem.test',
|
||||
secret: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||
}),
|
||||
);
|
||||
expect(url.protocol).toBe('otpauth:');
|
||||
expect(url.host).toBe('totp');
|
||||
const [issuer, account] = decodeURIComponent(
|
||||
url.pathname.replace(/^\//, ''),
|
||||
).split(':');
|
||||
expect(issuer).toBe('ARA SYSTEM');
|
||||
expect(account).toBe('doyun.lee@arasystem.test');
|
||||
expect(url.searchParams.get('issuer')).toBe(issuer);
|
||||
expect(url.searchParams.get('secret')).toBe(
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* 인증 앱 코드(TOTP — RFC 6238) · base32(RFC 4648) · otpauth 주소 (순수 함수) — 2단계 인증 등록의 첫 코드 확인에 쓴다
|
||||
* (계약 5절 "2단계 인증"). 새 의존성 없이 node crypto 로 만든다.
|
||||
*
|
||||
* 값은 인증 앱 기본값이자 엔진(Stalwart — totp-rs `from_url`)이 읽는 값과 같다: SHA-1 · 숫자 6자리 · 30초 · 앞뒤 한 단계.
|
||||
* 비밀 · 코드 · otpauth 주소는 어떤 로그에도 남기지 않는다 (부르는 쪽 몫 — 여기는 값을 만들고 견주기만 한다).
|
||||
*/
|
||||
|
||||
/** RFC 4648 base32 글자 (채움 `=` 은 쓰지 않는다) */
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const BITS_PER_BYTE = 8;
|
||||
const BITS_PER_BASE32_CHAR = 5;
|
||||
const BASE32_CHAR_MASK = 0b11111;
|
||||
|
||||
/** 코드 자릿수 · 한 단계 길이(초) · HMAC — otpauth 주소의 digits · period · algorithm 과 같아야 한다 */
|
||||
export const TOTP_DIGITS = 6;
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
const TOTP_HMAC = 'sha1';
|
||||
const TOTP_ALGORITHM_NAME = 'SHA1';
|
||||
/** 지금 단계의 앞뒤로 받는 단계 수 — 엔진(totp-rs 의 skew 1)과 같다: ±30초 */
|
||||
export const TOTP_WINDOW_STEPS = 1;
|
||||
|
||||
/** 숫자 6자리 — 요청 검사(VAL_001)와 같은 모양 */
|
||||
export const TOTP_CODE_PATTERN = /^\d{6}$/;
|
||||
|
||||
const MS_PER_SECOND = 1000;
|
||||
/** RFC 4226 의 동적 잘라내기 — 마지막 바이트의 아래 4비트가 자리, 맨 위 비트는 뗀다 */
|
||||
const DYNAMIC_OFFSET_MASK = 0x0f;
|
||||
const SIGN_BIT_MASK = 0x7f;
|
||||
const COUNTER_BYTES = 8;
|
||||
|
||||
/** 바이트 → base32 (RFC 4648, 채움 없음) — 20바이트(160비트)면 32자 */
|
||||
export function encodeBase32(bytes: Uint8Array): string {
|
||||
let output = '';
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const byte of bytes) {
|
||||
buffer = ((buffer << BITS_PER_BYTE) | byte) & 0xffff;
|
||||
bits += BITS_PER_BYTE;
|
||||
while (bits >= BITS_PER_BASE32_CHAR) {
|
||||
output +=
|
||||
BASE32_ALPHABET[
|
||||
(buffer >>> (bits - BITS_PER_BASE32_CHAR)) & BASE32_CHAR_MASK
|
||||
];
|
||||
bits -= BITS_PER_BASE32_CHAR;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output +=
|
||||
BASE32_ALPHABET[
|
||||
(buffer << (BITS_PER_BASE32_CHAR - bits)) & BASE32_CHAR_MASK
|
||||
];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** 그 시각(epoch ms)의 단계 번호 */
|
||||
export function totpCounterAt(epochMs: number): number {
|
||||
return Math.floor(epochMs / MS_PER_SECOND / TOTP_PERIOD_SECONDS);
|
||||
}
|
||||
|
||||
/** 단계 하나의 코드 (RFC 4226 HOTP — 숫자 6자리, 앞자리 0 을 채운다) */
|
||||
export function totpCode(secret: Uint8Array, counter: number): string {
|
||||
const message = Buffer.alloc(COUNTER_BYTES);
|
||||
message.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac(TOTP_HMAC, secret).update(message).digest();
|
||||
const offset = digest[digest.length - 1] & DYNAMIC_OFFSET_MASK;
|
||||
const binary =
|
||||
((digest[offset] & SIGN_BIT_MASK) << 24) |
|
||||
(digest[offset + 1] << 16) |
|
||||
(digest[offset + 2] << 8) |
|
||||
digest[offset + 3];
|
||||
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드가 맞는가 — 지금 단계와 앞뒤 TOTP_WINDOW_STEPS 단계 가운데 하나와 같으면 그 단계 번호, 아니면 null.
|
||||
* 모든 단계를 늘 같은 방식으로 견준다(상수 시간 비교 — 맞은 단계에서 멈추지 않는다).
|
||||
* 다시 쓴 코드 막기는 부르는 쪽 몫이다 (엔진도 막지 않는다 — 계약 5절)
|
||||
*/
|
||||
export function verifyTotp(
|
||||
secret: Uint8Array,
|
||||
code: string,
|
||||
epochMs: number,
|
||||
): number | null {
|
||||
if (!TOTP_CODE_PATTERN.test(code)) return null;
|
||||
const presented = Buffer.from(code);
|
||||
const current = totpCounterAt(epochMs);
|
||||
let matched: number | null = null;
|
||||
for (let step = -TOTP_WINDOW_STEPS; step <= TOTP_WINDOW_STEPS; step += 1) {
|
||||
const counter = current + step;
|
||||
if (counter < 0) continue;
|
||||
const expected = Buffer.from(totpCode(secret, counter));
|
||||
if (timingSafeEqual(expected, presented) && matched === null) {
|
||||
matched = counter;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 앱에 넣을 otpauth 주소 (Key Uri Format) — 라벨은 `발급자:주소`(각각 퍼센트 인코딩, 사이의 `:` 는 그대로), 발급자는 issuer 에도.
|
||||
* 예: otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=…&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30
|
||||
* (계약 5절 — 엔진이 이 주소를 저장하고 그 뒤 코드를 이 주소로 확인한다)
|
||||
*/
|
||||
export function buildOtpauthUrl(options: {
|
||||
readonly issuer: string;
|
||||
readonly account: string;
|
||||
/** base32 비밀 (encodeBase32) */
|
||||
readonly secret: string;
|
||||
}): string {
|
||||
const issuer = encodeURIComponent(options.issuer);
|
||||
const label = `${issuer}:${encodeURIComponent(options.account)}`;
|
||||
const query = [
|
||||
`secret=${options.secret}`,
|
||||
`issuer=${issuer}`,
|
||||
`algorithm=${TOTP_ALGORITHM_NAME}`,
|
||||
`digits=${TOTP_DIGITS}`,
|
||||
`period=${TOTP_PERIOD_SECONDS}`,
|
||||
].join('&');
|
||||
return `otpauth://totp/${label}?${query}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user