generated from ttipo/project-base
Compare commits
8 Commits
781ffb2e16
...
6106351351
| Author | SHA1 | Date | |
|---|---|---|---|
| 6106351351 | |||
| a7b9d00931 | |||
| 6c8c54d117 | |||
| ad6cea79fc | |||
| 1881ec7a42 | |||
| 452d9fef1d | |||
| 73babeaa2c | |||
| f0e7890bf1 |
@@ -16,6 +16,8 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
- 루트 `.env.{env}` 구조와 `*.example` ([[env-structure]])
|
||||
- 마이그레이션 **적용**(`migration:run`)과 배포 전 점검
|
||||
- `.github/workflows/` 와 `ci/` 러너
|
||||
- `mail/`(setup.mjs · resolver · clamav 설정 · mail/README) — 메일 엔진 설정은 setup 으로만 바꾼다
|
||||
- `ops/`(호스트 systemd 의 백업 · 복원 · 점검 스크립트와 유닛 — 13단계)
|
||||
|
||||
## 하지 않는 것
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
대용량 첨부를 쓰면 운영은 `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 가 같은 값을 읽는다
|
||||
13단계부터 운영은 `DB_IMAGE` 를 예시의 고정값(판 · digest)으로 두고, 첫 setup 은 복구 관리자와 함께(`--recovery` — 관리자 허용 IP 를 처음 걸 때 되돌릴 길) 돌린다.
|
||||
이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버" 차례를 따른다
|
||||
- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + 그 버킷만 쓰는 하위 계정 키, restic 비밀번호(**서버 밖에도 보관** — 잃으면 백업을 풀 수 없다),
|
||||
`/etc/arasystem/ops.env`, 타이머 셋(백업 03:30 · 점검 15분 · 부팅 되살리기) 켜기, 첫 백업 뒤 복원 연습(`drill`) 한 번. Healthchecks 체크 둘 · UptimeRobot 감시 셋 — 알림은 우리 메일 서버가 아닌 주소로
|
||||
- [ ] `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 경고를 낸다**
|
||||
|
||||
@@ -18,6 +18,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
|
||||
│ FRONTEND_PRIVATE_BIND(프록시가 다른 서버일 때만 — docker-compose.remote-proxy.yml, 사설 IPv4:포트) → 8080 게시
|
||||
│ SHARE_PUBLIC_URL · CORS_ORIGIN(11단계) → 공개 내려받기 server 이름 · 포털이 공개 경로를 닫는지 (nginx-env.envsh — 모양이 틀리면 기동 중단)
|
||||
│ MAIL_DOMAIN · MAIL_HOSTNAME(13단계) → 메일 프로그램 자동 설정 server(autoconfig. · autodiscover.) — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단
|
||||
├─▶ compose : COMPOSE_PROFILES · COMPOSE_FILE 도 --env-file 에서 읽는다 (운영의 remote-proxy 는 COMPOSE_FILE 로 — 빈 COMPOSE_FILE= 은 실패하니 주석으로)
|
||||
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
|
||||
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
|
||||
@@ -64,6 +65,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
|
||||
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
|
||||
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
|
||||
- **운영 스크립트(`ops/` — 호스트 systemd 의 백업 · 점검 · 부팅 되살리기, 13단계)도 예외** — 설정은 루트 env 가 아니라 서버의 `/etc/arasystem/ops.env` · `restic-password`(root 0600)에 둔다.
|
||||
ops.env: Object Storage 주소 · 키, Healthchecks 주소, `PROJECT_NAME` · `BACKUP_HOST`(새 서버 복원 때 필수 — 옛 서버의 값), `BACKUP_FINAL_TIMEOUT`(메일을 멈춘 final 의 상한, 기본 15m) · `BACKUP_HEALTHY_WAIT`(기본 300초).
|
||||
스크립트는 `.env.production` 의 `PROJECT_NAME` · `MAIL_HOSTNAME` · `CORS_ORIGIN` · `SHARE_PUBLIC_URL` · `MAIL_ADMIN_PASSWORD` · `MAIL_SPAMHAUS_DQS_KEY` 를 **읽기만 하고 복사해 두지 않는다**.
|
||||
`OPS_ALLOW_NO_TTY` 는 운영 도구(CLI)를 자동 시험에서 파이프로 돌릴 때만 `docker exec -e` 로 준다 — env 파일 · compose 에 두지 않는다.
|
||||
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
|
||||
- **루트 env 를 Node 스크립트가 직접 읽을 때**(`node --env-file=…`) 두 가지를 주의한다.
|
||||
- Node 는 **셸에 이미 있는 변수를 덮어쓰지 않는다** — 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 을 주면 개발 값으로 돈다.
|
||||
@@ -92,7 +97,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
|
||||
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, `COMPOSE_PROFILES`·`COMPOSE_FILE`(운영의 remote-proxy 일 때), 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`·`MAIL_RESOLVER_SUBNET`/`MAIL_RESOLVER_IP`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, `FRONTEND_PRIVATE_BIND`(remote-proxy 일 때),
|
||||
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> `DB_*`/`DB_DATA_DIR`·`DB_IMAGE`(13단계 — 판 · digest 고정), `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> 대용량 첨부(11단계) `SHARE_PUBLIC_URL`(운영 필수 — 포털과 다른 호스트)·`SHARE_REPORT_ADDRESS`·`SHARE_DOWNLOAD_LIMIT`·`MAIL_LARGE_FILE_MAX_SIZE_MB`/`_MAX_COUNT`/`_USER_QUOTA_GB`/`_RETENTION_DAYS`·
|
||||
> `FILE_STORAGE_MAX_TOTAL_GB`/`FILE_STORAGE_MIN_FREE_GB`·`CLAMAV_HOST`(운영 필수)/`CLAMAV_PORT`,
|
||||
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
|
||||
|
||||
@@ -36,6 +36,11 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." |
|
||||
| AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." |
|
||||
| AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." |
|
||||
| AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
@@ -47,6 +47,8 @@ this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
|
||||
|
||||
- 마스킹 유틸은 순수 함수로 `shared/utils/` 에 둔다 ([[coding-common]]).
|
||||
|
||||
- 메일 프로그램 자동 설정(`autoconfig.` · `autodiscover.` — 13단계)의 요청 주소에는 직원 주소가 쿼리로 실린다(`?emailaddress=`) — 앞단 nginx 는 쿼리 없이 남기고(위 규칙 그대로), 외부 프록시의 그 Proxy Host 는 접근 로그를 끈다 ([[docker]]).
|
||||
|
||||
## 클라이언트 로깅
|
||||
|
||||
- 프론트/앱에서 사용자 정보를 콘솔에 출력하지 않는다. 운영 빌드에는 디버그 로그를 남기지 않는다.
|
||||
@@ -56,5 +58,8 @@ this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
|
||||
|
||||
- 운영 환경은 JSON 포맷 + 외부 수집기(Loki/ELK/CloudWatch) 연동을 권장한다.
|
||||
- 파일로 남길 경우 로테이션(`winston-daily-rotate-file`)을 적용하고 보존 기간을 정한다.
|
||||
- **컨테이너 로그 보존**(13단계): compose 의 모든 서비스에 json-file 회전을 명시한다(x- 앵커 — 기본 10m×5, mail · frontend · backend 는 20m×10, 압축 — 운영에서 mail 2주 · frontend 1주쯤).
|
||||
새 서비스에도 앵커를 붙인다(CI 가 서비스 수와 logging 수를 견준다). 더 오래 남기려면 위 수집기로.
|
||||
- **운영 도구 실행 기록**(`ops_audit_log` — 13단계): 언제 · 누가(운영자 이름) · 명령 · 대상 · 결과. 대상 주소는 감사용이라 원문으로 둔다(DB 백업에 든다). 비밀번호 · 임시 비밀번호는 남기지 않는다.
|
||||
|
||||
> 보안 규칙은 [[security]], 테스트 규칙은 [[testing]] 참조.
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
|
||||
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
|
||||
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
|
||||
- **제한된 세션**(13단계 — 새 비밀번호 정하기 · 2단계 인증 켜기 전): 로그인은 되지만 허용한 경로(`@AllowRestrictedSession` — `/api/auth/*` · 2단계 켜기)만 지나고 그 밖은 403 `AUTH_010` 이다.
|
||||
제한을 푸는 요청은 **그 요청의 세션만** 풀고, 다음 제한이 있으면 그 제한으로 바꾼다. 2단계를 켜면 그 직원의 다른 세션 가운데 2단계를 거친 적 없는(`twoFactorSetup`) 세션은 끊는다 —
|
||||
비밀번호만 아는 다른 사람의 세션이 함께 열리지 않게(2026-11-02 리뷰). 엔진은 2단계를 강제하지 못하고, 비밀번호가 만료되면 웹메일(대리 접근)은 메일 오류만 보이므로 서버가 알아채 그 단계로 보낸다
|
||||
- **운영 도구**(서버 안 CLI — 13단계)의 관리자 비밀번호는 실행할 때 터미널에서만 받는다 — 화면에 보이지 않게 · 저장하지 않음 · 실행 기록 · 로그 · 예외 문장 · 프로세스 인자에 없음 · 입력 기록(↑) 0(`historySize: 0` — 2026-11-02 리뷰).
|
||||
표준 입력이 TTY 가 아니면 묻기 전에 멈춘다(자동 시험만 `OPS_ALLOW_NO_TTY=1`). 임시 비밀번호는 crypto 무작위 16자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다
|
||||
- **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다.
|
||||
되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
|
||||
- **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
|
||||
- **퇴사 마무리**(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다.
|
||||
끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron)
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
|
||||
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
|
||||
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
|
||||
@@ -110,10 +120,14 @@
|
||||
- 라우트 단위 조정: `@Throttle({ default: { ttl, limit } })` / 제외: `@SkipThrottle()`
|
||||
- 로그인·비밀번호 재설정처럼 브루트포스 표적이 되는 엔드포인트는 **더 엄격한 한도**를 따로 건다
|
||||
- ⚠️ rate limiter 를 이중으로 걸지 말 것 — 어느 쪽이 실제로 동작하는지 알 수 없게 되고 응답 헤더가 실제 한도와 어긋난다
|
||||
- **백업 비밀**(13단계): restic 비밀번호 · Object Storage 키는 서버의 `/etc/arasystem/`(root 0600)에 두고 **서버 밖(비밀번호 관리자)에도** 둔다 — 잃으면 백업을 풀 수 없다.
|
||||
백업에는 env 파일이 들어간다(암호화). 운영 스크립트는 비밀을 `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트로만 넘기고 명령줄(`ps` 로 보인다)에 싣지 않는다 — Healthchecks 주소도(`curl -K -`)
|
||||
- **컨테이너 root 실행 금지 — 개발 환경 포함.** 운영뿐 아니라 dev 오버라이드가 사용하는 빌드 스테이지도 비-root 여야 한다.
|
||||
- 프론트엔드 운영: `nginxinc/nginx-unprivileged` + `listen 8080` (비-root 는 1024 미만 포트 바인드 불가)
|
||||
- Node 스테이지: `RUN chown node:node /app` → `USER node` → `COPY --chown=node:node`
|
||||
(소유권을 먼저 넘겨야 익명 볼륨 `node_modules` 가 node 소유로 생성되어 캐시 쓰기가 가능하다)
|
||||
- 예외(13단계 — 호스트 systemd 의 백업 · 복원 보조 컨테이너): restic 은 다른 소유자(메일 엔진 UID 2000 등)의 볼륨을 읽으려고 root 로 돌되 `--cap-drop ALL` + `DAC_READ_SEARCH` ·
|
||||
`no-new-privileges` 만(복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER` 를 더함). 앱 컨테이너가 아니라 운영 스크립트가 잠깐 띄우는 것이다 ([[docker]] "백업 · 감시")
|
||||
- DB·Redis 는 `expose` 만 사용해 내부 네트워크에만 노출한다.
|
||||
개발 편의로 호스트에 열어야 한다면 **반드시 `127.0.0.1` 로 한정**한다 — `"${DB_PORT}:5432"` 는 `0.0.0.0` 바인딩이라
|
||||
같은 공유기(사무실·카페)에 있는 누구나 개발 DB 에 접속할 수 있다.
|
||||
|
||||
@@ -18,6 +18,10 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
평소의 4~7배 — 여유가 3~6배뿐이던 한도 두 곳이 CI 를 실패시켰다). 막으려는 옛 경로(제곱 등)는 그 한도를 확실히 넘어야 한다 — 둘 사이가 좁으면 입력을 키운다.
|
||||
벽시계는 다른 작업이 CPU 를 나눠 쓰면 몇 배로 늘어 CI 에서 흔들린다 (2026-10-27: 혼자 0.8초인 정화가 부하에서 9초).
|
||||
비동기 결과를 기다릴 때도 고정 대기(`setTimeout(20)`) 대신 조건이 될 때까지 짧게 다시 보고 넉넉한 기한을 둔다.
|
||||
- **시간대도 입력이다** — CI 는 UTC, 개발 PC 는 KST 다. 화면에 보이는 시각 · 날짜 글("오늘 오전 9:12")을 글자 그대로 보는 테스트는
|
||||
시각을 지역 시각으로 만들거나(`new Date(2026, 9, 31, 9, 12)`) 포매터에 시간대를 넘긴다(frontend `format.spec.ts` 의 `SEOUL`).
|
||||
UTC 로 적은 고정값(`'2026-10-31T00:12:00Z'`)은 KST 에서만 그 글이 된다(2026-11-02 — 12단계 테스트 셋이 CI 에서만 실패).
|
||||
시각을 보이는 화면을 고쳤으면 UTC 로도 돌려 본다(Git Bash — `TZ=UTC npm run test:unit -- --run`).
|
||||
- **커밋 전 실행** — 변경 영역의 테스트가 통과해야 한다. ([[checklist]])
|
||||
- **CI 가 강제한다** — `.github/workflows/ci.yml` 이 세 스택의 lint·test·build 와 에러 코드 동기화를 PR 마다 검사한다.
|
||||
- 테스트 설명(it/test 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
|
||||
|
||||
@@ -97,6 +97,10 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`CORS_ORIGIN` 의 호스트 이름들과 견준다(백엔드 `file-sharing.settings.ts` 와 같은 판정 — 포트는 보지 않는다).
|
||||
다르면 공개 server 가 화면 `/s/` · 화면 자산 · `/api/public-links/` **만** 열고(그 밖의 화면 404 · API 404 `RES_001` — 백엔드에 넘기지 않는다) 포털은 그 둘을 404 로 닫는다.
|
||||
비었거나 같으면(개발) 포털이 함께 열고 공개 server 이름은 어느 요청에도 맞지 않는 `share.invalid` 다.
|
||||
- **메일 프로그램 자동 설정 (13단계)**: server 가 하나 더 있다 — `autoconfig.<MAIL_DOMAIN>` · `autodiscover.<MAIL_DOMAIN>` 에 Thunderbird XML(두 경로) · Outlook POX 고정 응답(IMAP 993 · SMTP 465, `text/xml; charset=utf-8`).
|
||||
요청 값(주소 · 쿼리)을 되돌려 쓰지 않고 그 밖의 경로 · `/api` 는 404. 값은 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(`nginx-env.envsh` — 비면 꺼짐, 모양이 틀리거나 포털 · 공개 호스트와 겹치면 기동 중단).
|
||||
엔진의 자동 설정은 공개하지 않은 POP3 를 알리고 입력을 XML 에 되돌려 써서 쓰지 않는다. 전 직원 전환 때 DNS(두 이름 A → 외부 프록시, SRV `_imaps._tcp` · `_submissions._tcp` · 쓰지 않는 것은 "."),
|
||||
외부 프록시 Proxy Host(같은 frontend:8080 · **접근 로그 끔** — 쿼리에 직원 주소)를 만든다. 엔진 인증서의 Additional Hostnames 에는 이 두 이름을 넣지 않는다(mail/README "메일 운영 설정").
|
||||
- 공개 경로 · 오류 location 은 템플릿이 아닌 스니펫이다(이미지의 `/etc/nginx/snippets/` — `${NGINX_*}` 를 쓰지 않는다):
|
||||
`nginx-share-open.conf` · `nginx-share-closed.conf`(server 단계 — 화면 `/s/` 만), `nginx-share-api-open.conf` · `nginx-share-api-closed.conf`(`/api/` 블록 안 — 공개 API, `~*`),
|
||||
`nginx-api-errors.conf`(이름 있는 오류 location — `@api_not_found` 404 `RES_001` 포함). 공개 화면 `/s/` 는 `no-referrer`(주소에 토큰) · `noindex` · `no-cache`.
|
||||
@@ -212,7 +216,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
`sudo docker compose` 가 없는 디렉터리를 root 소유로 만들어 backend(node, UID 1000)가 쓰지 못한다. 새 named volume 은 이미지의
|
||||
`/app/uploads`(node 소유) 소유자를 물려받는다 (2026-10-27 운영 이미지로 확인). 개발만 `./data/uploads` 바인드 마운트다.
|
||||
백업은 메일 볼륨과 같은 방식: `sudo docker run --rm -v "${PROJECT_NAME}_uploads:/uploads:ro" -v "$PWD/backup:/backup" alpine tar czf "/backup/uploads-$(date +%Y%m%d).tar.gz" -C / uploads`.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
- **대용량 첨부 파일은 named volume `<PROJECT_NAME>_files`**(`/app/files` — 경로는 compose 가 `FILE_STORAGE_DIR` 로 고정, 루트 env 로 바꾸지 않는다)에 쌓인다. **개발도 named volume** 이다 —
|
||||
바인드 마운트면 2GB 를 Windows 너머로 쓰고 Defender 가 EICAR 시험 파일을 지운다. 그래서 `backend/Dockerfile` 의 builder 단계가 `/app/files` 를 node 소유로 만들어
|
||||
개발 볼륨도 소유자를 물려받는다(2026-10-30 확인). **백업하지 않는다** — 30일짜리 전송물이고 기록은 DB 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
|
||||
@@ -330,6 +334,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
"닫는 경로 시험"(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)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
13단계부터: 세 조합(운영 · 개발 · remote-proxy)의 서비스 수와 `logging` 수 견주기, `DB_IMAGE` 판 · digest 정규식, 자동 설정 server 의 렌더링 · 값 거절(겹침 포함) · 실제 요청 시험,
|
||||
운영 스크립트(`ops/`) shellcheck(scratch 이미지 — 빌드 컨텍스트로 넘긴다).
|
||||
ClamAV 설정은 compose 의 이미지로 `clamconf` 를 돌려 한도 · 경보 값을 본다 — `clamconf` 는 틀린 항목에도 종료 코드 0 이라 출력의 `ERROR` 를 찾고,
|
||||
`-n`(기본값과 같은 항목을 숨김) 없이 한 번 더 돌려 기본값에 기대는 `SelfCheck = "600"` · `HeuristicScanPrecedence disabled` 를 줄 그대로 확인한다. 이미지 줄은 끝까지 읽는 awk 로 고른다(위 pipefail 규칙).
|
||||
|
||||
@@ -410,6 +416,11 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
|
||||
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
|
||||
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
|
||||
- setup 은 엔진 관리자를 단단히 한다(13단계): 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` — 관리자 비밀번호로 `직원%관리자` 대리 로그인(바깥 993 · JMAP)이 막힌다(관리 API 는 그대로).
|
||||
영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 — 관리 API 주소가 앱 네트워크 안일 때만 걸고, 건 뒤 다시 로그인해 보고, 막히면 복구 관리자로 되돌린다. **운영에서 처음 걸 때는 복구 관리자가 있어야 한다**(없으면 걸지 않고 종료 1).
|
||||
운영자가 대역 안으로 좁혀 둔 값은 넓히지 않는다. 관리 화면 SSH 터널은 mail 컨테이너의 **앱 네트워크 주소**로 연다(다른 망 주소로 열면 403 — 엔진이 보는 출발 주소가 그 망의 게이트웨이).
|
||||
휴지통 · 스팸메일함 30일(`x:DataRetention.expungeTrashAfter`)과 정리 시각 KST 03:00 · 04:00 · 04:30(컨테이너 UTC 차이를 읽어)도 맞춘다 — 그 메일함에 들어간 때부터 세므로 imapsync 로 옮긴 메일도 옮긴 때부터다.
|
||||
자동 비우기는 특수 메일함("Deleted Items" · "Junk Mail")에만 걸린다 — imapsync 는 `--f1f2` 로 폴더를 맞춘다(mail/README imapsync 절). 이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버".
|
||||
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
|
||||
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
|
||||
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
|
||||
@@ -461,6 +472,20 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
---
|
||||
|
||||
## 백업 · 감시 (호스트 systemd — compose 밖, 13단계)
|
||||
|
||||
서버가 스스로 지키고 되살리는 일은 compose 가 아니라 호스트의 systemd 가 돌린다. 설치 · 복원 차례 · 알림 대응은 [`mail/README.md`](../../../mail/README.md) "백업" · "감시"가 SSOT 다.
|
||||
|
||||
- **자리**: 저장소의 `ops/` 를 서버의 `/opt/arasystem-ops` 로 `install -m 0644` 해 root 소유 사본으로 돌린다(저장소 폴더를 그대로 쓰지 않는다). 설정 · 비밀은 `/etc/arasystem/ops.env` · `restic-password`(root 0600) — [[env-structure]] 의 예외.
|
||||
- **유닛 셋**: `arasystem-backup.timer`(매일 03:30 KST · 부팅 뒤 따라잡기), `arasystem-check.timer`(15분 점검), `arasystem-mail-recover.service`(부팅 때 — 백업 표시가 남았으면 엔진을 띄운다, `Before=arasystem-backup.service`). 모두 `Wants=` + `After=docker.service`.
|
||||
- **백업**(restic → Object Storage, 보존 일 7 · 주 4 · 월 6, 일요일 prune · check 5%): 메일 볼륨은 켜 둔 채 미리 올리고(warm — restic 종료 3 은 받아들임) → 엔진을 잠깐 멈춰(9초 안팎) 바뀐 것만(final — 상한 `BACKUP_FINAL_TIMEOUT` 15분) → 다시 띄운다.
|
||||
DB `pg_dump -Fc`(+ `pg_restore -l` 확인) · 업로드 볼륨 · env 둘. 어떻게 끝나도 엔진을 다시 띄운다(EXIT trap + `ExecStopPost`, 멈추는 중 신호면 멈춤을 끝낸 뒤). 비밀 사본(env · 덤프)은 끝날 때 지운다.
|
||||
**매일 03:30 에 웹메일이 10초 안팎 `MAIL_001`** 이다(엔진 멈춤). 백업과 복원 연습은 같은 잠금 — 잠긴 동안 손으로 부른 엔진 띄우기는 건너뛴다(final 도중 띄우면 일관되지 않은 스냅숏이 final 이 된다).
|
||||
- **복원**(`arasystem-restore.sh` — snapshots · check · drill · files · volumes · db): final 태그 스냅숏만, 쓰는 컨테이너가 있거나 볼륨이 비지 않았으면 멈춘다(운영 볼륨을 덮지 않게), 새 서버는 `.env.production` 없이 ops.env 의 `PROJECT_NAME` 으로 files 부터. 분기마다 `drill`.
|
||||
- **15분 점검**: 디스크 두 곳 · 컨테이너 health · 백엔드 health · 메일 대기열 · 인증서 셋 · 백업 나이(26시간 이상) · ClamAV 서명 · Spamhaus 키 → Healthchecks.io(정상 · 실패 + 까닭 한 줄). 바깥은 UptimeRobot(포털 HTTPS · mx1:993 · mx1:25) — 알림은 우리 메일 서버가 아닌 주소로.
|
||||
- **비-root 예외**: restic 보조 컨테이너는 root 로 돌되 `--cap-drop ALL` + 필요한 권한만(읽기 `DAC_READ_SEARCH`, 복원은 `DAC_OVERRIDE` · `CHOWN` · `FOWNER`) · `no-new-privileges` ([[security]]). 연습 엔진(Stalwart)을 `no-new-privileges` 로 띄우려면 `NET_BIND_SERVICE` 가 든다.
|
||||
- 비밀은 명령줄에 싣지 않는다 — `docker exec -e 이름` · 표준 입력 · 읽기 전용 마운트, Healthchecks 주소도 `curl -K -`.
|
||||
|
||||
## 규칙
|
||||
|
||||
- **이미지 버전 고정 — 떠 있는 태그(`stable-alpine`·`v0.16`·`24-alpine`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
@@ -474,6 +499,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
|
||||
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
|
||||
| ClamAV (악성 검사 — 11단계) | `clamav/clamav:1.4.6_base@sha256:e7399106…` | `docker-compose.yml` 의 `clamav` · `clamav-updater`(같은 값 둘), `mail/README.md` "악성 검사 (ClamAV)"의 이미지 칸, 이 문서. LTS(1.4 — 2024-08 부터 3년)만 쓴다 — 같은 날 나온 1.5.4 대신 고른 까닭: 지원 기간, 1.5 의 새 ZIP 코드에서 나온 CVE-2026-20337. 공식 이미지지만 digest 까지 묶는다(CI 의 `clamconf` 검사는 compose 의 이미지를 읽어 쓴다) |
|
||||
| PostgreSQL (db — 13단계부터 고정) | `postgres:15.19-alpine3.24@sha256:54e0dd8e…` | 루트 예시 env 둘의 `DB_IMAGE`(각자의 로컬 env 도), CI 의 `DB_IMAGE` 정규식, 이 문서. `docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 같은 이미지를 빌려 쓴다. 15.19 는 2026-08-13 보안 릴리스, 이 digest 는 2026-10-21 alpine 재빌드라 운영 반영은 11-04 뒤. **메이저(15)를 올리려면 덤프 · 복원** — 데이터 형식이 다르다. db · redis 의 watchtower 표시는 뺐다(판은 사람이 올린다) |
|
||||
| restic (백업 — 13단계) | `restic/restic:0.19.1@sha256:136600b6…` | `ops/backup/arasystem-backup.sh` · `arasystem-restore.sh` 의 `RESTIC_IMAGE` 두 곳, 이 문서 |
|
||||
| shellcheck (CI — 13단계) | `koalaman/shellcheck:v0.11.0@sha256:61862eba…` | `.github/workflows/ci.yml` |
|
||||
| Redis (선택 — 기본 꺼짐) | `redis:7-alpine`(아직 떠 있는 태그 — 기존 드리프트) | 켤 때 판 · digest 로 고정하고 이 표에 줄을 둔다 |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
@@ -495,6 +524,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
24.22.1 은 2026-10-20 보안 릴리스라 보안 릴리스 규칙으로 바로 고정했다.
|
||||
- 올린 뒤에는 운영 이미지 두 개 빌드, CI 와 같은 명령(아래 "의존성은 컨테이너 안에서"처럼 임시 폴더 복사본에서 lint·type-check·test·build),
|
||||
dev 재빌드(`dc up -d --build -V`)로 확인한다.
|
||||
- **로그 보존 — 모든 서비스에 `logging` 앵커**(json-file · max-size · max-file · compress — 13단계). 기본 10m×5, mail · frontend · backend 는 20m×10. 앵커는 파일을 넘지 못해 dev · remote-proxy 는 자기 앵커를 둔다.
|
||||
새 서비스에도 붙인다 — CI 가 세 조합의 서비스 수와 logging 수를 견준다. 로그 한도는 컨테이너를 다시 만들어야 걸린다.
|
||||
- **빌드 최소화**: 의존성(package.json / pubspec 등) 변경이 없으면 `--build` 없이 기동한다.
|
||||
- **의존성을 바꿨다면 `--renew-anon-volumes` 를 함께 준다.**
|
||||
dev 는 `/app/node_modules` 를 익명 볼륨으로 마운트하는데, `--build` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
|
||||
@@ -42,6 +42,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
| 파일 저장 · 조각 올리기 (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` |
|
||||
| 전환 준비 (13단계) | `common/decorators/allow-restricted-session.decorator.ts` · `JwtAuthGuard` · `modules/account-security/two-factor-policy.service.ts` · `modules/system-settings` · `modules/system-mail` · `src/cli/ops.ts` + `modules/ops` · `modules/mail/jmap/jmap-ops-engine.ts` · `jmap-system-mailbox.ts` | **제한된 세션**(계약 6절): 세션 표의 `restriction`(`passwordChange` · `twoFactorSetup`)을 액세스 토큰(`rst`)에도 싣고, `JwtAuthGuard`(Reflector)가 `@AllowRestrictedSession()` 을 단 핸들러(`/api/auth/*` · 2단계 켜기 셋)만 지나게 한다 — 그 밖은 403 `AUTH_010`. 제한이 실린 토큰만 요청마다 세션 표의 지금 값을 읽는다(PK 한 번). 푸는 요청은 그 세션(`sid`)만 다음 제한으로 바꾸고(`replaceRestriction` — 조건부 UPDATE), 2단계를 켜면 그 직원의 다른 `twoFactorSetup` 세션은 끊는다(`revokeRestrictedOf` — 사유 `two-factor-not-verified`, 갱신에서도). **비밀번호 만료 알아채기**: 게이트웨이 대리 접근 세션에 메일 계정이 없고 `/api/account` 권한이 다섯 안이면 `PasswordExpiredException`(`jmap.client.ts` 의 `isPasswordExpiredPermissions` — 로그인 · 갱신 때 엔진 기준, 풀렸으면 지움). **2단계 필수 정책 · 문의 연락처**: 표 `system_settings`(한 줄 — 프로세스마다 10초 기억), `GET /api/support-contact`(로그인 없음 · `{ text }`), `twoFactorDeadline` 은 유예 중이고 아직 안 켠 직원만. **받은편지함 알림 메일**: 대리 접근 `Email/import`(안 읽음 · 필터 · 스팸 안 거침), 보낸 사람 `ARA SYSTEM <postmaster@도메인>`, 글은 디자인 README 13단계(링크 없음 · 이름 · 파일 이름 · 연락처 이스케이프 · 보내는 정화 통과), 실패는 warn(원래 일은 성공), 위험 파일은 처음 판정 때 한 번(`markInfected`). **운영 도구**(`sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js`): Nest 앱 컨텍스트(ScheduleModule 없음 · `FILE_SCANNER` null · `migrationsRun` false), 한국어 메뉴 → 바뀔 것 → y/N → 실행 → `ops_audit_log`. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0(`historySize: 0`) — 자동 시험만 `OPS_ALLOW_NO_TTY=1`. 관리자 Basic 은 `x:Account` · `x:MailingList` 에만, 직원 메일함 · Sieve · 앱 비밀번호는 게이트웨이 대리 접근(관리자에 impersonate 가 없다). 명령 열 개와 차례는 계약 6절(재설정: 앱 비밀번호 → 세션 끊기 → 알림 → 만료 → 세션 한 번 더 / 잠금: Sieve → authenticate 빼기 → 세션 / 퇴사 마무리: 엔진 계정 → 주소 키 표 한 트랜잭션(세션 행은 남김) · 예외 목록 · MailingList 구성원 → 디스크). 끊은 세션 기억은 15초마다 DB 에서 채운다(`revoked-sessions.ts` — 운영 도구는 다른 프로세스). 마이그레이션 `AddCutoverPrep`(`auth_sessions.restriction` · `system_settings` · `ops_audit_log`). Swagger 는 가드를 건 컨트롤러에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010) |
|
||||
| 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]]) |
|
||||
@@ -57,6 +58,7 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
|
||||
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
|
||||
> - **보호 라우트**: 인증은 구현되어 있다 (`modules/auth` + `common/guards/jwt-auth.guard.ts`). 새 보호 라우트는 컨트롤러·핸들러에
|
||||
> `@UseGuards(JwtAuthGuard)` + `@ApiCookieAuth()` 를 걸고 `@CurrentUser()` 로 직원을 받는다 (전역 가드가 아니다 — `users`·`health` 예제는 공개, `upload` 는 로그인 필요).
|
||||
> 13단계부터 그 클래스에 `@ApiSessionErrors()`(AUTH_001 + AUTH_010 — 문서)도 단다. 제한된 세션(새 비밀번호 · 2단계 켜기 전)도 불러야 하는 핸들러만 `@AllowRestrictedSession()` — 계약 6절 목록뿐이고, 달지 않은 보호 라우트는 403 `AUTH_010` 이다.
|
||||
> 권한(AUTH_003) 구분이 필요해지면 역할 가드를 `common/guards/` 에 추가한다.
|
||||
> 반면 출처 검사(`OriginGuard`)는 **전역**이다 — 웹훅처럼 다른 사이트가 정당하게 부르는 엔드포인트가 생기면 예외 표시를 따로 만든다
|
||||
> - **메일 엔진 교체**: `modules/mail/mail.module.ts` 의 `MAIL_PROVIDER` 구현(useClass)만 바꾼다 — 서비스·컨트롤러는 포트만 안다
|
||||
@@ -251,7 +253,7 @@ SwaggerModule.setup('api-docs', app, document);
|
||||
> 계정 보안(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 · 웹훅 거절 로그 묶음은 인스턴스마다 돌아도 무해하다.
|
||||
> 13단계부터 15초마다 DB 에서 채우므로 인스턴스 사이도 15초 안에 맞는다(운영 도구가 끊은 세션도 같은 길). 2단계 필수 정책 · 문의 연락처의 10초 기억(`system-settings`)도 인스턴스마다 따로지만 10초 안에 맞는다. 검사가 겹치면 위험 파일 알림 메일도 겹칠 수 있다(조건부 UPDATE 로 줄였다). 로그인 기록 정리 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 라 그대로 맞다:
|
||||
@@ -419,6 +421,9 @@ dc exec backend npm uninstall <패키지>
|
||||
메일을 지우지 않는다 — `Email/set update` 만 쓴다(옮기기는 `ifInState` — 어긋나면 다시 확인, 표시는 조건 없이).
|
||||
**붙잡힌 메일**(9단계 — 보내기 취소 · 예약을 기다리는 보낸 사본)을 옮기거나 지우는 새 경로를 만들면 `pending-send.ts` 의 `readHeldEmails` 로 먼저 묻고 막는다
|
||||
(`MAIL_015` · `failedIds` · `skippedIds` — 읽지 못하면 바꾸지 않고 `MAIL_001`). 엔진은 옮기거나 지운 메일도 시간이 되면 보낸다.
|
||||
**비밀번호가 만료된 계정**(13단계 — 관리자가 `credentials/0/expiresAt` 을 지난 날로)은 권한이 다섯(`authenticate` · `authenticateWithAlias` · `sysAccountPasswordGet` · `sysAccountPasswordUpdate` · `emailReceive`)뿐이다 —
|
||||
대리 접근으로 메일 · `Email/import` · Sieve · `x:AppPassword` 가 막히고, 동적 관리자 확인(`assertNoManagementAccess`)도 보지 못한다(정적 막는 목록이 지킨다). 서버가 넣는 알림 메일을 지우는 `jmap-system-mailbox.ts` 의 remove 는
|
||||
조건 없는 destroy 의 예외다(방금 넣은 그 메일만). `x:Account/get` 은 properties 에 `@type` 을 적어야 계정 종류를 돌려준다(0.16.27).
|
||||
**엔진 Basic 의 402 는 장애가 아니다**(12단계) — 비밀번호는 맞고 그 직원이 2단계 인증(OTP)을 켰다는 뜻이다. 웹메일 로그인은 코드 단계로, 비밀번호 바꾸기는 대리 접근 + `otpAuth.otpCode` 로 간다(지금까지 `MAIL_001` 로 다뤄 OTP 를 켠 직원이 들어오지 못했다).
|
||||
2단계 · 앱 비밀번호 · 웹훅 설정을 고칠 때는 계약 5절의 엔진 사실(엔진은 등록 때 코드를 보지 않는다 · 같은 코드를 다시 받는다 · 앱 비밀번호 기본 권한은 앱 비밀번호를 또 만든다)을 함께 본다.
|
||||
**발송 제출은 envelope 을 직접 준다**(`jmap/envelope.ts`) — 주지 않으면 엔진이 받지 않는 주소(모르는 최상위 도메인 등)를 아무 말 없이 빼고 나머지에게만 보낸다(2026-10-29 확인).
|
||||
|
||||
@@ -65,6 +65,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 보낸 사람 차단 · 보내기 · 읽기 설정 (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단계" |
|
||||
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
|
||||
| 시계 주입 | `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]`) |
|
||||
|
||||
@@ -40,7 +40,11 @@ MAIL_RESOLVER_IP=172.16.73.254
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 —
|
||||
# 바꾸면 다음 up 에서 db 가 다시 만들어진다(데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다)
|
||||
# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 이미지로는 2주가 2026-11-04 에 찬다
|
||||
# (개발은 지금 써도 된다 — 운영에 올리는 것은 11-04 뒤, .env.production.example 의 같은 줄)
|
||||
DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=change-me-in-local-env
|
||||
DB_NAME=project_db
|
||||
|
||||
@@ -38,7 +38,11 @@ MAIL_RESOLVER_IP=172.16.73.254
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
# ==========================================
|
||||
DB_IMAGE=postgres:15-alpine
|
||||
# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 —
|
||||
# 바꾸면 다음 up 에서 db 가 다시 만들어진다(몇 초 끊김 · 데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다).
|
||||
# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 2주 규칙으로 운영에 올리는 것은 2026-11-04 뒤.
|
||||
# 올리기 전에 백업이 그날 돌았는지 본다 (mail/README.md "백업" · "13단계 전부터 운영 중인 서버")
|
||||
DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984
|
||||
DB_USER=app_user
|
||||
DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
DB_NAME=project_db
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
run: npm run build-only
|
||||
|
||||
infra:
|
||||
name: Infra (compose · Dockerfile · nginx)
|
||||
name: Infra (compose · Dockerfile · nginx · 운영 스크립트)
|
||||
runs-on: ubuntu-latest
|
||||
# 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다
|
||||
timeout-minutes: 10
|
||||
@@ -150,6 +150,22 @@ jobs:
|
||||
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
|
||||
fi
|
||||
# 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다).
|
||||
# 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴)
|
||||
# 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다
|
||||
for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \
|
||||
"-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \
|
||||
"-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do
|
||||
# shellcheck disable=SC2086
|
||||
services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l)
|
||||
# shellcheck disable=SC2086
|
||||
logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }')
|
||||
[ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; }
|
||||
done
|
||||
# DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정")
|
||||
for envf in .env.development.example .env.production.example; do
|
||||
grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; }
|
||||
done
|
||||
|
||||
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
|
||||
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
|
||||
@@ -182,13 +198,35 @@ jobs:
|
||||
docker build --target nginx-config -t "$tag" frontend
|
||||
# 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf)
|
||||
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
|
||||
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다
|
||||
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t
|
||||
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null)
|
||||
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다.
|
||||
# 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.<MAIL_DOMAIN>)도 켠다
|
||||
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t
|
||||
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -T 2>/dev/null)
|
||||
for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \
|
||||
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do
|
||||
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;' \
|
||||
'server_name autoconfig.ci.test autodiscover.ci.test;' '<hostname>mx1.ci.test</hostname>' '<Server>mx1.ci.test</Server><Port>993</Port>'; do
|
||||
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
|
||||
done
|
||||
# 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름
|
||||
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \
|
||||
|| { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; }
|
||||
# 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다
|
||||
for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
for value in 'mx1.ci.test<x>' 'mx1.ci.test:993' 'mx1'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
# 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기)
|
||||
for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then
|
||||
echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
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)
|
||||
@@ -285,7 +323,7 @@ jobs:
|
||||
EOF
|
||||
docker build -t "$tag" "$ctx"
|
||||
docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \
|
||||
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag"
|
||||
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test "$tag"
|
||||
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; }
|
||||
|
||||
@@ -355,6 +393,59 @@ jobs:
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test).
|
||||
# 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404)
|
||||
- name: 메일 프로그램 자동 설정 시험
|
||||
run: |
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
docker exec -i "$name" sh -s <<'EOF'
|
||||
fails=0
|
||||
# host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r'
|
||||
}
|
||||
ok() { echo "ok $1"; }
|
||||
bad() { echo "FAIL $1"; fails=$((fails + 1)); }
|
||||
has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; }
|
||||
hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; }
|
||||
INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test'
|
||||
r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ")
|
||||
has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8"
|
||||
has "Thunderbird" "$r" "<hostname>mx1.ci.test</hostname>"; has "Thunderbird" "$r" "<port>993</port>"; has "Thunderbird" "$r" "<port>465</port>"
|
||||
has "Thunderbird" "$r" "<username>%EMAILADDRESS%</username>"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3"
|
||||
r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml)
|
||||
has ".well-known" "$r" "<hostname>mx1.ci.test</hostname>"
|
||||
BODY='<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress><script>evil@ci.test</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>'
|
||||
for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do
|
||||
r=$(raw autodiscover.ci.test POST "$p" "$BODY")
|
||||
has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "<Server>mx1.ci.test</Server><Port>993</Port>"; has "Outlook $p" "$r" "<Type>SMTP</Type>"
|
||||
hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script"
|
||||
done
|
||||
for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do
|
||||
r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404"
|
||||
done
|
||||
# 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다
|
||||
r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "<clientConfig"
|
||||
r=$(raw files.ci.test GET /mail/config-v1.1.xml); has "공개 호스트" "$r" "HTTP/1.1 404"
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 운영 서버의 백업 · 점검 스크립트(13단계 — ops/) — shellcheck(판 · digest 고정). 설정 파일 예시에 비밀이 없는지도 본다
|
||||
- name: 운영 스크립트 검사
|
||||
run: |
|
||||
# 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다). 이 이미지에는 셸이 없다(scratch) — 그대로 shellcheck 를 부른다
|
||||
tag="ci-shellcheck:${{ github.run_id }}"
|
||||
printf 'FROM koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d\nCOPY . /mnt/ops\nWORKDIR /mnt/ops\n' | docker build -q -t "$tag" -f- ops >/dev/null
|
||||
docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh
|
||||
for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; }
|
||||
done
|
||||
if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then
|
||||
echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1
|
||||
fi
|
||||
|
||||
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
|
||||
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
|
||||
@@ -401,6 +492,7 @@ jobs:
|
||||
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-shellcheck:${{ github.run_id }}" || true
|
||||
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
||||
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
/redis-data/
|
||||
# CI 러너 등록 정보
|
||||
ci/runner-data/
|
||||
# 운영 서버의 백업 · 감시 설정(13단계 — S3 키 · Healthchecks 주소 · restic 비밀번호). 서버의 /etc/arasystem/ 에 두지만 저장소 안에 잘못 만들어도 올라가지 않게
|
||||
/ops/*.env
|
||||
/ops/restic-password
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
backend/uploads/
|
||||
# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
import { FileStorageModule } from '@/modules/file-storage/file-storage.module';
|
||||
import { HealthModule } from '@/modules/health/health.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
@@ -77,6 +78,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
// 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다
|
||||
FileStorageModule,
|
||||
FileSharingModule,
|
||||
// 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다
|
||||
SystemSettingsModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
|
||||
import { OpsCli } from '@/modules/ops/ops-cli';
|
||||
import { OPS_ALLOW_NO_TTY_ENV, TerminalIo } from '@/modules/ops/ops-io';
|
||||
import { OpsModule } from '@/modules/ops/ops.module';
|
||||
|
||||
/**
|
||||
* 운영 도구 (13단계 — 계약 6절 "운영 도구") — 서버 안에서 사람이 돌리는 명령:
|
||||
* sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js
|
||||
* 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다. 한 일은 ops_audit_log 에 남는다.
|
||||
* Nest 앱 컨텍스트로 띄운다 — HTTP 서버 · cron · 실시간 엔진 연결 · 악성 검사 · 마이그레이션 없이 (modules/ops/ops.module.ts).
|
||||
* 화면에는 경고 · 오류 로그만 섞인다(info 로그는 끈다)
|
||||
*/
|
||||
async function main(): Promise<number> {
|
||||
const app = await NestFactory.createApplicationContext(OpsModule, {
|
||||
logger: ['error', 'warn'],
|
||||
});
|
||||
// 표준 입력이 터미널이 아니면 도구가 비밀번호를 묻기 전에 멈춘다(-it 를 빠뜨림). 자동 시험만 OPS_ALLOW_NO_TTY=1 로 파이프를 받는다
|
||||
const io = new TerminalIo(process.stdin, process.stdout, {
|
||||
allowNonInteractive: process.env[OPS_ALLOW_NO_TTY_ENV] === '1',
|
||||
});
|
||||
try {
|
||||
return await app.get(OpsCli).run(io);
|
||||
} finally {
|
||||
io.close();
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then((code) => {
|
||||
process.exitCode = code;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(
|
||||
`운영 도구를 시작하지 못했습니다: ${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 제한된 세션 (계약 6절 "제한된 세션" — 13단계) — 로그인은 했지만 먼저 해야 할 일이 있는 세션.
|
||||
* - passwordChange: 엔진의 비밀번호가 만료됐다(관리자가 준 임시 비밀번호) — 새 비밀번호를 정할 때까지
|
||||
* - twoFactorSetup: 회사가 2단계 인증을 필수로 정했고 시작일이 지났는데 아직 켜지 않았다 — 켤 때까지
|
||||
* 차례는 passwordChange 가 먼저다. 제한은 세션 표(auth_sessions.restriction)와 액세스 토큰(`rst`)에 있다
|
||||
*/
|
||||
export const SESSION_RESTRICTIONS = [
|
||||
'passwordChange',
|
||||
'twoFactorSetup',
|
||||
] as const;
|
||||
export type SessionRestriction = (typeof SESSION_RESTRICTIONS)[number];
|
||||
|
||||
export function isSessionRestriction(
|
||||
value: unknown,
|
||||
): value is SessionRestriction {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
(SESSION_RESTRICTIONS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** 메타데이터 키 — JwtAuthGuard 가 읽는다 */
|
||||
export const ALLOW_RESTRICTED_SESSION = 'allowRestrictedSession';
|
||||
|
||||
/**
|
||||
* 제한된 세션으로도 부를 수 있는 핸들러 · 컨트롤러 (계약 6절) — `/api/auth/*`(me · password · sessions) ·
|
||||
* `/api/two-factor` · `enrollments` · `activation` 만 단다. 달지 않은 보호 라우트는 제한된 세션이면 403 AUTH_010 이다(JwtAuthGuard).
|
||||
* 로그인 없이 부르는 라우트(로그인 · 갱신 · 로그아웃 · 문의 연락처)는 가드가 없어 상관없다
|
||||
*/
|
||||
export const AllowRestrictedSession = (): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(ALLOW_RESTRICTED_SESSION, true);
|
||||
@@ -69,3 +69,28 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */
|
||||
const SESSION_REQUIRED_DESCRIPTION =
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) — 클라이언트는 /auth/refresh 를 한 번 시도한다';
|
||||
|
||||
/**
|
||||
* 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전).
|
||||
* `@UseGuards(JwtAuthGuard)` 를 건 컨트롤러(클래스)에 함께 붙인다 — 가드가 두 실패를 모두 낸다. 핸들러에 같은 상태 코드를 따로 적으면 그것이 이긴다.
|
||||
* 제한된 세션도 부르는 곳(`@AllowRestrictedSession()` — 인증 · 로그인된 브라우저 · 2단계 상태 · 등록 · 켜기)에는 붙이지 않는다 — AUTH_001 만 적는다
|
||||
*
|
||||
* 사용 예:
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiSessionErrors()
|
||||
*/
|
||||
export function ApiSessionErrors(
|
||||
description: string = SESSION_REQUIRED_DESCRIPTION,
|
||||
) {
|
||||
return applyDecorators(
|
||||
ApiErrorResponse('AUTH_001', description),
|
||||
ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) — 이 API 는 부를 수 없다. 클라이언트는 알림 없이 me 로 제한을 다시 받는다 (13단계)',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SessionRestriction } from './allow-restricted-session.decorator';
|
||||
|
||||
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
|
||||
export interface AuthenticatedUser {
|
||||
@@ -19,6 +20,16 @@ export interface AuthenticatedUser {
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
/**
|
||||
* 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다.
|
||||
* JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다
|
||||
*/
|
||||
readonly restriction?: SessionRestriction | null;
|
||||
/**
|
||||
* 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰의 `tfd`. 아니면 null · 없음.
|
||||
* 로그인 · 갱신 때 정해 토큰에 싣는다 (`me` 가 엔진 · DB 에 묻지 않고 답한다)
|
||||
*/
|
||||
readonly twoFactorDeadline?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,12 @@ describe('에러 코드 레지스트리', () => {
|
||||
expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('제한된 세션(AUTH_010 — 13단계)은 403 이고, 403 의 상태 역매핑은 그대로 AUTH_003 이다', () => {
|
||||
expect(ERROR_CODES.AUTH_010.status).toBe(HttpStatus.FORBIDDEN);
|
||||
// AUTH_010 은 가드가 명시적으로만 던진다 — 프레임워크의 403 은 권한 부족(AUTH_003)으로 둔다
|
||||
expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
it('같은 이름의 메일함(MAIL_009)은 409 다', () => {
|
||||
expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,12 @@ const COMMON_ERROR_CODES = {
|
||||
message:
|
||||
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
},
|
||||
// 제한된 세션(비밀번호 바꾸기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API 를 불렀다.
|
||||
// 화면은 알림 없이 me 를 다시 받아 그 제한의 단계로 간다 (계약 6절 "제한된 세션"). 403 의 상태 역매핑은 그대로 AUTH_003 이다
|
||||
AUTH_010: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthModuleOptions } from '@nestjs/passport';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@@ -14,7 +19,7 @@ function codeOf(run: () => unknown): string | undefined {
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard.handleRequest', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
|
||||
it('검증된 사용자는 그대로 통과시킨다', () => {
|
||||
expect(guard.handleRequest(null, user, undefined)).toBe(user);
|
||||
@@ -69,3 +74,93 @@ describe('JwtAuthGuard.handleRequest', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 제한된 세션 시험용 — 허용한 컨트롤러 · 허용하지 않은 컨트롤러와 핸들러 */
|
||||
@AllowRestrictedSession()
|
||||
class AllowedController {}
|
||||
|
||||
class MixedController {}
|
||||
|
||||
const meHandler = (): void => undefined;
|
||||
const statusHandler = (): void => undefined;
|
||||
const deactivateHandler = (): void => undefined;
|
||||
// 핸들러 하나에만 표시 — 메서드 데코레이터와 같은 메타데이터
|
||||
AllowRestrictedSession()(statusHandler);
|
||||
|
||||
function contextOf(
|
||||
controller: abstract new () => object,
|
||||
handler: () => void,
|
||||
): ExecutionContext {
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => controller,
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('JwtAuthGuard — 제한된 세션 (계약 6절, 13단계)', () => {
|
||||
const guard = new JwtAuthGuard(new Reflector());
|
||||
const restricted = { ...user, restriction: 'passwordChange' as const };
|
||||
|
||||
it('제한 없는 세션은 허용 표시가 없는 핸들러도 통과한다', () => {
|
||||
const context = contextOf(MixedController, deactivateHandler);
|
||||
expect(guard.handleRequest(null, user, undefined, context)).toBe(user);
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
{ ...user, restriction: null },
|
||||
undefined,
|
||||
context,
|
||||
),
|
||||
).toEqual({ ...user, restriction: null });
|
||||
});
|
||||
|
||||
it('제한된 세션은 허용 표시가 없는 핸들러에서 403 AUTH_010 이다 — 제한은 로그 context 에만', () => {
|
||||
const context = contextOf(MixedController, deactivateHandler);
|
||||
expect(
|
||||
codeOf(() => guard.handleRequest(null, restricted, undefined, context)),
|
||||
).toBe('AUTH_010');
|
||||
try {
|
||||
guard.handleRequest(null, restricted, undefined, context);
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).context).toEqual({
|
||||
reason: 'session-restricted',
|
||||
restriction: 'passwordChange',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('핸들러나 컨트롤러에 @AllowRestrictedSession() 이 있으면 제한된 세션도 통과한다', () => {
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
restricted,
|
||||
undefined,
|
||||
contextOf(MixedController, statusHandler),
|
||||
),
|
||||
).toBe(restricted);
|
||||
expect(
|
||||
guard.handleRequest(
|
||||
null,
|
||||
{ ...user, restriction: 'twoFactorSetup' },
|
||||
undefined,
|
||||
contextOf(AllowedController, meHandler),
|
||||
),
|
||||
).toEqual({ ...user, restriction: 'twoFactorSetup' });
|
||||
});
|
||||
|
||||
it('실행 맥락을 모르면 제한된 세션을 막는다 (닫힌 쪽)', () => {
|
||||
expect(codeOf(() => guard.handleRequest(null, restricted, undefined))).toBe(
|
||||
'AUTH_010',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JwtAuthGuard — PassportModule 설정', () => {
|
||||
it('PassportModule.register 의 설정(AuthModuleOptions)을 부모 가드에 넘긴다 (리뷰 L13)', () => {
|
||||
const options = Object.assign(new AuthModuleOptions(), { session: false });
|
||||
const guard = new JwtAuthGuard(new Reflector(), options);
|
||||
expect((guard as unknown as { options: AuthModuleOptions }).options).toBe(
|
||||
options,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { type ExecutionContext, Injectable, Optional } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard, AuthModuleOptions } from '@nestjs/passport';
|
||||
|
||||
import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
@@ -12,16 +14,29 @@ export const JWT_STRATEGY = 'jwt';
|
||||
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
|
||||
*
|
||||
* 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 —
|
||||
* 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 —
|
||||
* 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽).
|
||||
*
|
||||
* 사용 예:
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiCookieAuth()
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
|
||||
// PassportModule.register 의 설정(AuthModuleOptions)을 부모에 그대로 넘긴다 — 생성자를 두면 Nest 가 부모의 주입을 대신하지 않는다
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
@Optional() options?: AuthModuleOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
error: unknown,
|
||||
user: unknown,
|
||||
info: unknown,
|
||||
context?: ExecutionContext,
|
||||
): TUser {
|
||||
// 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다
|
||||
if (error instanceof BusinessException) throw error;
|
||||
@@ -31,8 +46,26 @@ export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) {
|
||||
context: { reason: describeFailure(error ?? info) },
|
||||
});
|
||||
}
|
||||
|
||||
const restriction = (user as AuthenticatedUser).restriction ?? null;
|
||||
if (restriction !== null && !this.allowsRestricted(context)) {
|
||||
throw new BusinessException('AUTH_010', {
|
||||
context: { reason: 'session-restricted', restriction },
|
||||
});
|
||||
}
|
||||
return user as TUser;
|
||||
}
|
||||
|
||||
/** 핸들러 · 컨트롤러에 @AllowRestrictedSession() 이 있는가 — 실행 맥락이 없으면(가드 밖에서 부름) 막는다 */
|
||||
private allowsRestricted(context: ExecutionContext | undefined): boolean {
|
||||
if (!context) return false;
|
||||
return (
|
||||
this.reflector.getAllAndOverride<boolean | undefined>(
|
||||
ALLOW_RESTRICTED_SESSION,
|
||||
[context.getHandler(), context.getClass()],
|
||||
) === true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 전환 준비 표 (docs/mail/api-contract.md 6절 "전환 준비 — 13단계").
|
||||
* - auth_sessions.restriction: 제한된 세션 — passwordChange(비밀번호 바꾸기 전) · twoFactorSetup(2단계 인증 켜기 전) · null. 기존 세션은 null(제한 없음)
|
||||
* - system_settings: 서버 설정 한 줄(id 1) — 2단계 인증 필수 정책(꺼짐 · 시작일 · 예외 주소)과 문의 연락처. 처음 값은 꺼짐 · 연락처 없음이다
|
||||
* - ops_audit_log: 운영 도구의 실행 기록 — 언제 · 누가 · 명령 · 대상 · 결과(비밀은 남기지 않는다). 일어난 때 인덱스
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/auth/auth-session.entity.ts · modules/system-settings/system-settings.entity.ts · modules/ops/ops-audit-log.entity.ts —
|
||||
* 만든 뒤 migration:generate 가 "변경 없음"인 것을 확인했다)
|
||||
*/
|
||||
export class AddCutoverPrep1793585725784 implements MigrationInterface {
|
||||
name = 'AddCutoverPrep1793585725784';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "restriction" character varying(32)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "system_settings" ("id" smallint NOT NULL, "two_factor_required" boolean NOT NULL, "two_factor_start_date" date, "two_factor_exempt" jsonb NOT NULL, "support_contact" character varying(200), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_82521f08790d248b2a80cc85d40" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
// 한 줄만 둔다 — 처음 값: 2단계 인증 필수 꺼짐 · 예외 없음 · 문의 연락처 없음
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "system_settings" ("id", "two_factor_required", "two_factor_start_date", "two_factor_exempt", "support_contact", "updated_at") VALUES (1, false, NULL, '[]', NULL, now())`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "ops_audit_log" ("id" BIGSERIAL NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "operator" character varying(100) NOT NULL, "command" character varying(32) NOT NULL, "target" character varying(254), "result" character varying(16) NOT NULL, "detail" character varying(500), CONSTRAINT "PK_79f2db0d5b46ba1d43348bbc85c" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ops_audit_log_occurred_at" ON "ops_audit_log" ("occurred_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ops_audit_log_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "ops_audit_log"`);
|
||||
await queryRunner.query(`DROP TABLE "system_settings"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "restriction"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { Logger, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthSessionModule } from '@/modules/auth/auth-session.module';
|
||||
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 { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
@@ -26,6 +28,7 @@ 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 { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
import { TwoFactorController } from './two-factor.controller';
|
||||
import { TwoFactorService } from './two-factor.service';
|
||||
@@ -64,6 +67,9 @@ function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
// 로그인 기록의 실패를 사내 목록으로 거른다
|
||||
ContactsModule,
|
||||
LoginAttemptModule,
|
||||
// 13단계 — 2단계 인증 필수 정책(서버 설정)과, 켜면 세션의 twoFactorSetup 제한 지우기
|
||||
SystemSettingsModule,
|
||||
AuthSessionModule,
|
||||
],
|
||||
controllers: [
|
||||
TwoFactorController,
|
||||
@@ -85,6 +91,7 @@ function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
TwoFactorEnrollments,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
TwoFactorPolicyService,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
LoginEventsCleanup,
|
||||
@@ -93,6 +100,8 @@ function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
],
|
||||
exports: [
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
// 13단계 — 인증 모듈이 로그인 · 갱신에서 2단계 인증 필수 정책을 본다
|
||||
TwoFactorPolicyService,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
AppPasswordsService,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -56,7 +57,7 @@ function toDto(view: AppPasswordView): AppPasswordDto {
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('app-passwords')
|
||||
export class AppPasswordsController {
|
||||
constructor(private readonly appPasswords: AppPasswordsService) {}
|
||||
|
||||
@@ -24,6 +24,23 @@ export class TwoFactorStatusDto {
|
||||
example: '2026-10-31T00:12:00.000Z',
|
||||
})
|
||||
enabledAt!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 직원에게 2단계 인증이 필수인가 (13단계 — 회사 정책, 시작일 전에도 true). 필수인 직원은 끄지 못한다(끄기는 400 VAL_001)',
|
||||
example: false,
|
||||
})
|
||||
isRequired!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'필수의 유예 중이고 아직 켜지 않았으면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 켰거나 시작했거나 필수가 아니면 null(로그인 · me 의 twoFactorDeadline 과 같은 규칙). 화면은 그 1ms 앞 날짜(시작일 전날)까지로 안내한다',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-11-30T15:00:00.000Z',
|
||||
})
|
||||
deadline!: string | null;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -41,7 +42,7 @@ function toDto(event: LoginEventEntity): LoginEventDto {
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('login-events')
|
||||
export class LoginEventsController {
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service';
|
||||
import { DEFAULT_SYSTEM_SETTINGS } from '@/modules/system-settings/system-settings.types';
|
||||
import type { AccountSecurityProvider } from './account-security.provider';
|
||||
import { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
import type { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
|
||||
describe('TwoFactorPolicyService — 2단계 인증 켜짐 판정 (13단계)', () => {
|
||||
let records: { existsBy: jest.Mock; delete: jest.Mock };
|
||||
let provider: { isTwoFactorEnabled: jest.Mock };
|
||||
let service: TwoFactorPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
records = {
|
||||
existsBy: jest.fn().mockResolvedValue(true),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
provider = { isTwoFactorEnabled: jest.fn().mockResolvedValue(false) };
|
||||
service = new TwoFactorPolicyService(
|
||||
{
|
||||
get: jest.fn().mockResolvedValue(DEFAULT_SYSTEM_SETTINGS),
|
||||
} as unknown as SystemSettingsService,
|
||||
records as unknown as Repository<TwoFactorSettingEntity>,
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it('isEnabledInEngine — 우리 기록이 아니라 엔진에 묻는다: 기록이 남았어도 엔진이 꺼짐이면 false 이고 기록을 지운다 (재리뷰 N1)', async () => {
|
||||
await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false);
|
||||
expect(records.existsBy).not.toHaveBeenCalled();
|
||||
expect(provider.isTwoFactorEnabled).toHaveBeenCalledWith(ARA);
|
||||
expect(records.delete).toHaveBeenCalledWith({ userEmail: ARA });
|
||||
});
|
||||
|
||||
it('isEnabledInEngine — 엔진이 켜짐이면 true, 기록은 건드리지 않는다', async () => {
|
||||
provider.isTwoFactorEnabled.mockResolvedValueOnce(true);
|
||||
await expect(service.isEnabledInEngine(ARA)).resolves.toBe(true);
|
||||
expect(records.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('isEnabledInEngine — 기록 정리가 실패해도 판정은 그대로, 엔진 장애는 던진다', async () => {
|
||||
records.delete.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false);
|
||||
|
||||
provider.isTwoFactorEnabled.mockRejectedValueOnce(new Error('engine down'));
|
||||
await expect(service.isEnabledInEngine(ARA)).rejects.toThrow('engine down');
|
||||
});
|
||||
|
||||
it('isEnabled — 기록이 있으면 켜짐(엔진에 묻지 않는다), 없으면 엔진', async () => {
|
||||
await expect(service.isEnabled(ARA)).resolves.toBe(true);
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
records.existsBy.mockResolvedValueOnce(false);
|
||||
provider.isTwoFactorEnabled.mockResolvedValueOnce(true);
|
||||
await expect(service.isEnabled(ARA)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { SystemSettingsService } from '@/modules/system-settings/system-settings.service';
|
||||
import {
|
||||
sessionTwoFactorState,
|
||||
twoFactorRequirementFor,
|
||||
type SessionTwoFactorState,
|
||||
type TwoFactorRequirement,
|
||||
} from '@/modules/system-settings/system-settings.types';
|
||||
import {
|
||||
ACCOUNT_SECURITY_PROVIDER,
|
||||
type AccountSecurityProvider,
|
||||
} from './account-security.provider';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
|
||||
/**
|
||||
* 2단계 인증 필수 정책의 판정 (13단계 — 계약 6절 "2단계 인증 필수") — 정책은 서버 설정(system_settings), 켜짐은 엔진이 기준이다.
|
||||
* - 로그인: 엔진의 답(402 면 켜짐)을 그대로 넘겨받는다 (knownEnabled)
|
||||
* - 갱신: 우리 기록(two_factor_settings — 웹메일에서 켠 때)이 있으면 켜짐, 없으면 엔진에 묻는다
|
||||
* (엔진 관리 화면에서 켠 직원은 기록이 없다 — 기록만 보면 켠 직원을 막게 된다)
|
||||
* 정책이 꺼졌거나 예외인 직원은 엔진에 묻지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorPolicyService {
|
||||
constructor(
|
||||
private readonly settings: SystemSettingsService,
|
||||
@InjectRepository(TwoFactorSettingEntity)
|
||||
private readonly records: Repository<TwoFactorSettingEntity>,
|
||||
@Inject(ACCOUNT_SECURITY_PROVIDER)
|
||||
private readonly provider: AccountSecurityProvider,
|
||||
) {}
|
||||
|
||||
/** 이 직원에게 정책이 걸리는가 (GET /api/two-factor 의 isRequired · deadline, 끄기 막기) */
|
||||
async requirementFor(
|
||||
email: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<TwoFactorRequirement> {
|
||||
const { twoFactor } = await this.settings.get();
|
||||
return twoFactorRequirementFor(twoFactor, email, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션에 붙일 2단계 인증 상태 — 제한(twoFactorSetup) 또는 유예 중의 시작 시각.
|
||||
* `knownEnabled` 를 모르면(갱신) 우리 기록 → 엔진 차례로 본다 — 엔진 장애는 그대로 던진다(갱신은 세션을 남기고 503)
|
||||
*/
|
||||
async sessionState(
|
||||
email: string,
|
||||
knownEnabled?: boolean,
|
||||
now: Date = new Date(),
|
||||
): Promise<SessionTwoFactorState> {
|
||||
const requirement = await this.requirementFor(email, now);
|
||||
if (!requirement.isRequired) {
|
||||
return { restriction: null, deadline: null };
|
||||
}
|
||||
const enabled = knownEnabled ?? (await this.isEnabled(email));
|
||||
return sessionTwoFactorState(requirement, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진에서 2단계 인증이 켜져 있는가 — 우리 기록을 보지 않는다. 엔진이 꺼짐인데 기록이 남았으면(엔진 관리 화면 · 운영 도구에서 껐다) 기록을 지운다.
|
||||
* 갱신이 2단계를 거치지 않은 세션을 끊을지 정할 때 쓴다 — 오래된 기록 때문에 제한된 세션이 갱신마다 끊기지 않게(재리뷰 N1). 엔진 장애는 그대로 던진다
|
||||
*/
|
||||
async isEnabledInEngine(email: string): Promise<boolean> {
|
||||
const enabled = await this.provider.isTwoFactorEnabled(email);
|
||||
if (!enabled) {
|
||||
// 기록 정리는 실패해도 판정은 그대로다 — 다음 상태 읽기(GET /api/two-factor)도 맞춘다
|
||||
await this.records.delete({ userEmail: email }).catch(() => undefined);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** 2단계 인증이 켜져 있는가 — 우리 기록(웹메일에서 켠 때) → 엔진. 엔진 장애는 그대로 던진다 */
|
||||
async isEnabled(email: string): Promise<boolean> {
|
||||
if (await this.records.existsBy({ userEmail: email })) return true;
|
||||
return this.provider.isTwoFactorEnabled(email);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
@@ -26,12 +27,17 @@ import {
|
||||
TwoFactorEnrollmentDto,
|
||||
TwoFactorStatusDto,
|
||||
} from './dto/two-factor.dto';
|
||||
import { TwoFactorService, type TwoFactorStatus } from './two-factor.service';
|
||||
import {
|
||||
TwoFactorService,
|
||||
type TwoFactorStatusView,
|
||||
} from './two-factor.service';
|
||||
|
||||
function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto {
|
||||
function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
|
||||
return {
|
||||
isEnabled: status.isEnabled,
|
||||
enabledAt: status.enabledAt?.toISOString() ?? null,
|
||||
isRequired: status.isRequired,
|
||||
deadline: status.deadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,25 +50,34 @@ function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto {
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010
|
||||
@Controller('two-factor')
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactor: TwoFactorService) {}
|
||||
|
||||
@Get()
|
||||
@AllowRestrictedSession()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 상태',
|
||||
description:
|
||||
'엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다.',
|
||||
'엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다. ' +
|
||||
'13단계: isRequired · deadline — 회사의 2단계 인증 필수 정책. 제한된 세션도 부른다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async status(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(await this.twoFactor.getStatus(user.email));
|
||||
return toStatusDto(
|
||||
await this.twoFactor.withPolicy(
|
||||
user.email,
|
||||
await this.twoFactor.getStatus(user.email),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('enrollments')
|
||||
@AllowRestrictedSession()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
|
||||
@@ -90,13 +105,15 @@ export class TwoFactorController {
|
||||
}
|
||||
|
||||
@Post('activation')
|
||||
@AllowRestrictedSession()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 켜기 — 첫 코드와 지금 비밀번호',
|
||||
description:
|
||||
'코드를 등록의 비밀로 먼저 확인한다(앞뒤 30초) — 엔진은 등록 때 코드를 보지 않는다. 둘 다 틀렸으면 코드부터 알린다(AUTH_006). ' +
|
||||
'그다음 지금 비밀번호를 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다) 엔진에 넣는다. 켠 코드는 2분 동안 다시 받지 않는다. ' +
|
||||
'다른 곳의 웹메일 로그인은 끊지 않는다.',
|
||||
'다른 곳의 웹메일 로그인은 끊지 않는다. 13단계: 켜면 이 요청의 세션의 twoFactorSetup 제한을 지우고(클라이언트는 곧바로 /auth/refresh 로 새 토큰을 받는다), ' +
|
||||
'그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것(2단계를 거친 적이 없는 세션)은 끊는다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '켰다' })
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@@ -121,17 +138,16 @@ export class TwoFactorController {
|
||||
@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 },
|
||||
),
|
||||
const status = await this.twoFactor.activate(
|
||||
user.email,
|
||||
{
|
||||
enrollmentId: dto.enrollmentId,
|
||||
otpCode: dto.otpCode,
|
||||
currentPassword: dto.currentPassword,
|
||||
},
|
||||
{ ip, sessionId: user.sessionId ?? null },
|
||||
);
|
||||
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
|
||||
}
|
||||
|
||||
@Post('deactivation')
|
||||
@@ -140,14 +156,19 @@ export class TwoFactorController {
|
||||
summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드',
|
||||
description:
|
||||
'비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' +
|
||||
'코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다.',
|
||||
'코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다. ' +
|
||||
'13단계: 회사 정책으로 필수인 직원은 400 VAL_001(비밀번호를 묻기 전에). 제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, {
|
||||
description: '껐다 (또는 이미 꺼져 있었다)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 · 회사 정책으로 필수인 직원 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
|
||||
@ApiErrorResponse(
|
||||
@@ -159,12 +180,11 @@ export class TwoFactorController {
|
||||
@Body() dto: TwoFactorDeactivationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(
|
||||
await this.twoFactor.deactivate(
|
||||
user.email,
|
||||
{ currentPassword: dto.currentPassword, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
),
|
||||
const status = await this.twoFactor.deactivate(
|
||||
user.email,
|
||||
{ currentPassword: dto.currentPassword, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
);
|
||||
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AuthSessionRepository } from '@/modules/auth/auth-session.repository';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
TWO_FACTOR_CODE_ADDRESS_LIMIT,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import type { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
import type { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
import { TwoFactorService } from './two-factor.service';
|
||||
|
||||
@@ -122,9 +124,24 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
let service: TwoFactorService;
|
||||
let logs: string[];
|
||||
let errors: string[];
|
||||
/** 2단계 인증 필수 정책 대역 (13단계) — 처음은 필수 아님 */
|
||||
let policy: { requirementFor: jest.Mock };
|
||||
/** 세션 표 대역 — 켜면 twoFactorSetup 제한을 지운다 (13단계) */
|
||||
let sessions: { clearRestriction: jest.Mock; revokeRestrictedOf: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
engineEnabled = false;
|
||||
policy = {
|
||||
requirementFor: jest.fn().mockResolvedValue({
|
||||
isRequired: false,
|
||||
startsAt: null,
|
||||
isStarted: false,
|
||||
}),
|
||||
};
|
||||
sessions = {
|
||||
clearRestriction: jest.fn().mockResolvedValue(true),
|
||||
revokeRestrictedOf: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
provider = {
|
||||
isTwoFactorEnabled: jest.fn<Promise<boolean>, [string]>(() =>
|
||||
Promise.resolve(engineEnabled),
|
||||
@@ -162,6 +179,8 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
codes,
|
||||
locks,
|
||||
attempts,
|
||||
policy as unknown as TwoFactorPolicyService,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
);
|
||||
logs = [];
|
||||
errors = [];
|
||||
@@ -777,4 +796,142 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
).toBe('AUTH_001');
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('2단계 인증 필수 정책 (13단계 — 계약 6절)', () => {
|
||||
it('켜면 요청 세션의 twoFactorSetup 제한만 지우고, twoFactorSetup 이 붙은 다른 세션은 끊는다 (리뷰 M1)', async () => {
|
||||
sessions.revokeRestrictedOf.mockResolvedValueOnce(2);
|
||||
const { enrollment, code } = await enroll();
|
||||
await service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP, sessionId: 'sid-mine' },
|
||||
);
|
||||
expect(sessions.clearRestriction).toHaveBeenCalledWith(
|
||||
'sid-mine',
|
||||
'twoFactorSetup',
|
||||
);
|
||||
expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'twoFactorSetup',
|
||||
'two-factor-not-verified',
|
||||
expect.any(Date),
|
||||
'sid-mine',
|
||||
);
|
||||
// 그 직원의 모든 세션 제한을 한꺼번에 지우지 않는다 — 비밀번호만 아는 다른 사람의 세션이 2단계 없이 열리지 않게
|
||||
expect(sessions.clearRestriction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('요청 세션을 모르면(sid 없는 옛 토큰) 지우지 않고, twoFactorSetup 이 붙은 세션은 모두 끊는다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
await service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP },
|
||||
);
|
||||
expect(sessions.clearRestriction).not.toHaveBeenCalled();
|
||||
expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'twoFactorSetup',
|
||||
'two-factor-not-verified',
|
||||
expect.any(Date),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('제한 정리가 실패해도 켜기는 성공이다 — error 로그만(갱신이 다시 본다)', async () => {
|
||||
sessions.clearRestriction.mockRejectedValueOnce(new Error('db down'));
|
||||
const { enrollment, code } = await enroll();
|
||||
const status = await service.activate(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
otpCode: code(),
|
||||
currentPassword: PASSWORD,
|
||||
},
|
||||
{ ip: IP, sessionId: 'sid-mine' },
|
||||
);
|
||||
expect(status.isEnabled).toBe(true);
|
||||
expect(errors.join('\n')).toContain('세션의 제한을 정리하지 못했습니다');
|
||||
});
|
||||
|
||||
it('필수인 직원의 끄기는 400 VAL_001 — 비밀번호를 묻기 전에 막고 엔진에 보내지 않는다', async () => {
|
||||
engineEnabled = true;
|
||||
policy.requirementFor.mockResolvedValue({
|
||||
isRequired: true,
|
||||
startsAt: new Date('2026-11-30T15:00:00.000Z'),
|
||||
isStarted: false,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.deactivate(
|
||||
ARA,
|
||||
{ currentPassword: PASSWORD, otpCode: '123456' },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('VAL_001');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.disableTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('응답에 정책을 더한다 — 유예 중이고 꺼져 있으면 시작 시각, 켰거나 시작했거나 필수가 아니면 null', async () => {
|
||||
const startsAt = new Date('2026-11-30T15:00:00.000Z');
|
||||
policy.requirementFor.mockResolvedValueOnce({
|
||||
isRequired: true,
|
||||
startsAt,
|
||||
isStarted: false,
|
||||
});
|
||||
await expect(
|
||||
service.withPolicy(ARA, { isEnabled: false, enabledAt: null }),
|
||||
).resolves.toEqual({
|
||||
isEnabled: false,
|
||||
enabledAt: null,
|
||||
isRequired: true,
|
||||
deadline: startsAt,
|
||||
});
|
||||
|
||||
policy.requirementFor.mockResolvedValueOnce({
|
||||
isRequired: true,
|
||||
startsAt,
|
||||
isStarted: true,
|
||||
});
|
||||
await expect(
|
||||
service.withPolicy(ARA, { isEnabled: true, enabledAt: null }),
|
||||
).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: null,
|
||||
isRequired: true,
|
||||
deadline: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.withPolicy(ARA, { isEnabled: false, enabledAt: null }),
|
||||
).resolves.toMatchObject({ isRequired: false, deadline: null });
|
||||
|
||||
// 유예 중이어도 이미 켰으면 null — 켠 직원에게는 켤 기한이 없다
|
||||
policy.requirementFor.mockResolvedValueOnce({
|
||||
isRequired: true,
|
||||
startsAt,
|
||||
isStarted: false,
|
||||
});
|
||||
await expect(
|
||||
service.withPolicy(ARA, { isEnabled: true, enabledAt: null }),
|
||||
).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: null,
|
||||
isRequired: true,
|
||||
deadline: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from 'crypto';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AuthSessionRepository } from '@/modules/auth/auth-session.repository';
|
||||
import {
|
||||
isEmployeeAddress,
|
||||
normalizeEmail,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
enrollmentExpiry,
|
||||
} from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
import { TwoFactorSettingEntity } from './two-factor-setting.entity';
|
||||
|
||||
/** GET /api/two-factor 의 모양 — 켜기 · 끄기의 응답도 같다 */
|
||||
@@ -41,6 +43,14 @@ export interface TwoFactorStatus {
|
||||
readonly enabledAt: Date | null;
|
||||
}
|
||||
|
||||
/** 응답의 모양 — 상태 + 2단계 인증 필수 정책 (13단계 — 계약 6절 "2단계 인증 필수") */
|
||||
export interface TwoFactorStatusView extends TwoFactorStatus {
|
||||
/** 이 직원에게 필수인가 — 정책이 켜져 있고 예외가 아니다(시작일 전에도). 필수인 직원은 끄지 못한다 */
|
||||
readonly isRequired: boolean;
|
||||
/** 필수의 유예 중이면 시작 시각(시작일 0시 KST) — 아니면 null */
|
||||
readonly deadline: Date | null;
|
||||
}
|
||||
|
||||
/** 등록 — 비밀 · otpauth 주소는 이 응답에만 (no-store) */
|
||||
export interface TwoFactorEnrollmentView {
|
||||
readonly enrollmentId: string;
|
||||
@@ -53,6 +63,8 @@ export interface TwoFactorEnrollmentView {
|
||||
/** 요청한 클라이언트 — 실패 한도의 IP (모르면 빈 값 · undefined — 'unknown' 한 묶음으로 센다) */
|
||||
export interface SecurityClient {
|
||||
readonly ip: string | undefined;
|
||||
/** 요청의 세션(액세스 토큰의 sid) — 2단계를 켜면 이 세션의 제한만 지운다(13단계). 모르면 null */
|
||||
readonly sessionId?: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
@@ -91,8 +103,30 @@ export class TwoFactorService {
|
||||
private readonly codes: TwoFactorCodeGuard,
|
||||
private readonly locks: TwoFactorLocks,
|
||||
private readonly attempts: LoginAttemptService,
|
||||
/** 13단계 — 2단계 인증 필수 정책(끄기 막기 · 응답의 isRequired · deadline), 켜면 세션의 twoFactorSetup 제한을 지운다 */
|
||||
private readonly policy: TwoFactorPolicyService,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 상태에 정책을 더한다 — GET · 켜기 · 끄기의 응답 (계약 6절: isRequired · deadline).
|
||||
* deadline 은 로그인 · me 의 twoFactorDeadline 과 같은 규칙이다 — 유예 중이고 아직 켜지 않았을 때만 시작 시각
|
||||
*/
|
||||
async withPolicy(
|
||||
user: string,
|
||||
status: TwoFactorStatus,
|
||||
): Promise<TwoFactorStatusView> {
|
||||
const requirement = await this.policy.requirementFor(normalizeEmail(user));
|
||||
return {
|
||||
...status,
|
||||
isRequired: requirement.isRequired,
|
||||
deadline:
|
||||
requirement.isRequired && !requirement.isStarted && !status.isEnabled
|
||||
? requirement.startsAt
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/two-factor — 엔진에서 꺼졌으면 켠 때 기록을 지운다 */
|
||||
async getStatus(user: string): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
@@ -192,6 +226,7 @@ export class TwoFactorService {
|
||||
}
|
||||
|
||||
const enabledAt = await this.recordEnabledAt(address);
|
||||
await this.settleSetupRestriction(address, client.sessionId ?? null);
|
||||
this.logger.log(`2단계 인증을 켰습니다: ${maskEmail(address)}`);
|
||||
return { isEnabled: true, enabledAt };
|
||||
});
|
||||
@@ -246,7 +281,42 @@ export class TwoFactorService {
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과) */
|
||||
/**
|
||||
* 2단계를 켰다 — **요청의 세션**(액세스 토큰의 sid)의 twoFactorSetup 제한만 지우고, 그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것은 끊는다
|
||||
* (2단계를 거친 적이 없는 세션 — 비밀번호만 아는 다른 사람의 세션일 수 있다, 계약 6절 "제한된 세션").
|
||||
* 실패해도 켜기는 성공이다(error 로그) — 갱신이 다시 본다: 세션 표가 twoFactorSetup 인데 계정이 켜짐이면 그 세션을 끊는다(이 세션이면 다시 로그인)
|
||||
*/
|
||||
private async settleSetupRestriction(
|
||||
address: string,
|
||||
sessionId: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (sessionId !== null) {
|
||||
await this.sessions.clearRestriction(sessionId, 'twoFactorSetup');
|
||||
}
|
||||
const ended = await this.sessions.revokeRestrictedOf(
|
||||
address,
|
||||
'twoFactorSetup',
|
||||
'two-factor-not-verified',
|
||||
new Date(),
|
||||
sessionId,
|
||||
);
|
||||
if (ended > 0) {
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 2단계 인증을 켜 2단계를 거치지 않은 다른 세션 ${ended}개를 끊었습니다: user=${maskEmail(address)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 2단계 인증을 켰지만 세션의 제한을 정리하지 못했습니다 — 다음 갱신이 다시 봅니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과).
|
||||
* 2단계 인증이 필수인 직원(정책 — 시작일 전 포함)은 400 VAL_001 — 비밀번호를 묻기 전에 막는다 (계약 6절)
|
||||
*/
|
||||
async deactivate(
|
||||
user: string,
|
||||
input: { readonly currentPassword: string; readonly otpCode: string },
|
||||
@@ -254,6 +324,14 @@ export class TwoFactorService {
|
||||
): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
if ((await this.policy.requirementFor(address)).isRequired) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'two-factor-required-by-policy',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.locks.run(address, async () => {
|
||||
if (isAppPasswordShape(input.currentPassword)) {
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** SHA-256 16진 문자열 길이 */
|
||||
@@ -10,6 +12,8 @@ const REVOKED_REASON_LENGTH = 32;
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
/** User-Agent 원문 길이 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
/** 제한 이름 길이 — 'passwordChange' · 'twoFactorSetup' (여유를 둔다) */
|
||||
const RESTRICTION_COLUMN_LENGTH = 32;
|
||||
|
||||
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
|
||||
export type SessionRevokedReason =
|
||||
@@ -21,7 +25,14 @@ export type SessionRevokedReason =
|
||||
/** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */
|
||||
| 'password-change-uncertain'
|
||||
/** 직원이 로그인된 브라우저 목록에서 끊었다 — 하나 · 다른 곳 모두 (계약 5절 "로그인된 브라우저") */
|
||||
| 'ended-by-user';
|
||||
| 'ended-by-user'
|
||||
/** 운영 도구가 끊었다 — 비밀번호 재설정 · 잠금 (계약 6절 "운영 도구") */
|
||||
| 'ended-by-operator'
|
||||
/**
|
||||
* 2단계 인증을 거친 적이 없는 세션(twoFactorSetup 제한)인데 그 직원이 2단계를 켰다 — 다른 세션에서 켰을 때 · 갱신에서 계정이 이미 켜짐일 때
|
||||
* (비밀번호만 아는 다른 사람의 세션일 수 있다 — 계약 6절 "제한된 세션")
|
||||
*/
|
||||
| 'two-factor-not-verified';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
@@ -30,6 +41,7 @@ export type SessionRevokedReason =
|
||||
* - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증). 12단계부터 액세스 토큰에 세션 id(sid)를 싣고,
|
||||
* 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다)
|
||||
* - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null
|
||||
* - 제한(restriction — 13단계)은 로그인 · 갱신 때 정하고 액세스 토큰에도 싣는다. 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 지운다 (계약 6절 "제한된 세션")
|
||||
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
|
||||
*/
|
||||
@Entity({ name: 'auth_sessions' })
|
||||
@@ -97,4 +109,15 @@ export class AuthSessionEntity {
|
||||
nullable: true,
|
||||
})
|
||||
userAgent!: string | null;
|
||||
|
||||
/**
|
||||
* 제한된 세션 (13단계 — 계약 6절) — passwordChange · twoFactorSetup · null(제한 없음).
|
||||
* 로그인 때 엔진의 답(비밀번호 만료 · 2단계 켜짐)과 정책으로 정하고, 갱신 때 다시 정한다. 푸는 요청이 성공하면 지운다
|
||||
*/
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: RESTRICTION_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
restriction!: SessionRestriction | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, MoreThan, type Repository } from 'typeorm';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
AuthSessionEntity,
|
||||
type SessionRevokedReason,
|
||||
@@ -18,6 +19,8 @@ export interface RotateSessionCommand {
|
||||
/** 이 갱신의 클라이언트 IP · User-Agent 원문 — 로그인된 브라우저 목록 (계약 5절). 모르면 null */
|
||||
readonly ip: string | null;
|
||||
readonly userAgent: string | null;
|
||||
/** 이 갱신에서 다시 정한 제한 (13단계 — 계약 6절 "제한된 세션") — 없으면 null */
|
||||
readonly restriction: SessionRestriction | null;
|
||||
}
|
||||
|
||||
/** 조건부 UPDATE … RETURNING id 의 결과 행 */
|
||||
@@ -81,11 +84,75 @@ export class AuthSessionRepository {
|
||||
expiresAt: command.nextExpiresAt,
|
||||
ip: command.ip,
|
||||
userAgent: command.userAgent,
|
||||
restriction: command.restriction,
|
||||
},
|
||||
);
|
||||
return result.affected === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 하나의 제한을 바꾼다 — 지금 `from` 일 때만(조건부 UPDATE 한 번) (13단계 — 푸는 요청이 성공했다: 다음 제한으로 · 없으면 null).
|
||||
* 그사이 다른 제한으로 바뀌었으면 건드리지 않는다. 바꿨으면 true. 끝난 세션도 상관없다(쓰이지 않는다)
|
||||
*/
|
||||
async replaceRestriction(
|
||||
id: string,
|
||||
from: SessionRestriction,
|
||||
to: SessionRestriction | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.sessions.update(
|
||||
{ id, restriction: from },
|
||||
{ restriction: to },
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 세션 하나의 제한을 지운다 — 지금 그 제한일 때만 → 지웠으면 true */
|
||||
clearRestriction(
|
||||
id: string,
|
||||
restriction: SessionRestriction,
|
||||
): Promise<boolean> {
|
||||
return this.replaceRestriction(id, restriction, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원의 살아 있는 세션 가운데 그 제한이 붙은 것을 끝낸다 — `keepId` 세션만 남긴다 → 끝낸 수 (13단계 — 2단계를 켰다:
|
||||
* twoFactorSetup 이 붙은 다른 세션은 2단계를 거친 적이 없다). 끊은 세션 기억 · 15초 동기화를 탄다(revokeWhere)
|
||||
*/
|
||||
async revokeRestrictedOf(
|
||||
userEmail: string,
|
||||
restriction: SessionRestriction,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
keepId: string | null,
|
||||
): Promise<number> {
|
||||
const ids = await this.revokeWhere(
|
||||
`user_email = :userEmail AND restriction = :restriction AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now${keepId !== null ? ' AND id <> :keepId' : ''}`,
|
||||
{ userEmail, restriction, now, ...(keepId !== null ? { keepId } : {}) },
|
||||
reason,
|
||||
now,
|
||||
);
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 하나를 끝낸다 — 지금 그 제한이 붙어 있고 끝나지 않았을 때만(조건부 UPDATE 한 번) → 끝냈으면 true (13단계 — 갱신이 2단계를 거치지 않은 세션을 끊는다.
|
||||
* 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않는다)
|
||||
*/
|
||||
async revokeIfRestricted(
|
||||
id: string,
|
||||
restriction: SessionRestriction,
|
||||
reason: SessionRevokedReason,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const ids = await this.revokeWhere(
|
||||
'id = :id AND restriction = :restriction AND revoked_at IS NULL',
|
||||
{ id, restriction },
|
||||
reason,
|
||||
now,
|
||||
);
|
||||
return ids.length > 0;
|
||||
}
|
||||
|
||||
/** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다. 끝냈으면 true */
|
||||
async revoke(
|
||||
id: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
@@ -34,9 +35,11 @@ import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto';
|
||||
|
||||
// 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다.
|
||||
// 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션)
|
||||
// 13단계 — /api/auth/* 라 제한된 세션도 부른다(계약 6절 "제한된 세션")
|
||||
@ApiTags('Auth')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowRestrictedSession()
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('auth/sessions')
|
||||
export class AuthSessionsController {
|
||||
|
||||
@@ -31,6 +31,7 @@ const session = (
|
||||
revokedReason: null,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: CHROME,
|
||||
restriction: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
|
||||
/**
|
||||
* JWT 페이로드.
|
||||
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
|
||||
@@ -16,6 +18,13 @@ export interface AuthTokenPayload {
|
||||
* 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다
|
||||
*/
|
||||
readonly sid?: string;
|
||||
/**
|
||||
* 제한된 세션의 제한 (13단계 — 계약 6절 "제한된 세션") — 액세스 토큰에만, 제한이 있을 때만 싣는다.
|
||||
* 가드가 허용한 핸들러 밖이면 403 AUTH_010. 세션 표의 restriction 과 같은 값이다(로그인 · 갱신 때 정한다)
|
||||
*/
|
||||
readonly rst?: SessionRestriction;
|
||||
/** 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰에만, 유예 중일 때만 */
|
||||
readonly tfd?: string;
|
||||
/** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */
|
||||
readonly jti?: string;
|
||||
readonly iat?: number;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { AuthController } from './auth.controller';
|
||||
@@ -22,6 +23,14 @@ const session = {
|
||||
refreshToken: 'new.refresh',
|
||||
refreshTtlSeconds: 1209600,
|
||||
},
|
||||
state: { restriction: null, twoFactorDeadline: null },
|
||||
};
|
||||
|
||||
/** 응답 본문 — 직원 + 제한 · 유예 (13단계) */
|
||||
const sessionBody = {
|
||||
...session.user,
|
||||
restriction: null,
|
||||
twoFactorDeadline: null,
|
||||
};
|
||||
|
||||
function fakeResponse() {
|
||||
@@ -60,6 +69,7 @@ describe('AuthController', () => {
|
||||
refresh: jest.Mock;
|
||||
logout: jest.Mock;
|
||||
changePassword: jest.Mock;
|
||||
sessionStateOf: jest.Mock;
|
||||
};
|
||||
let controller: AuthController;
|
||||
|
||||
@@ -69,6 +79,9 @@ describe('AuthController', () => {
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn().mockResolvedValue(undefined),
|
||||
changePassword: jest.fn().mockResolvedValue(undefined),
|
||||
sessionStateOf: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ restriction: null, twoFactorDeadline: null }),
|
||||
};
|
||||
controller = new AuthController(auth as unknown as AuthService, settings);
|
||||
});
|
||||
@@ -118,7 +131,7 @@ describe('AuthController', () => {
|
||||
'203.0.113.10',
|
||||
{ otpCode: '123456', userAgent: CHROME },
|
||||
);
|
||||
expect(result).toEqual({ status: 'signedIn', ...session.user });
|
||||
expect(result).toEqual({ status: 'signedIn', ...sessionBody });
|
||||
expect(res.cookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -147,6 +160,8 @@ describe('AuthController', () => {
|
||||
status: 'twoFactorRequired',
|
||||
email: null,
|
||||
name: null,
|
||||
restriction: null,
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -166,7 +181,7 @@ describe('AuthController', () => {
|
||||
ip: '203.0.113.10',
|
||||
userAgent: CHROME,
|
||||
});
|
||||
expect(user).toEqual(session.user);
|
||||
expect(user).toEqual(sessionBody);
|
||||
expect(res.cookie).toHaveBeenCalledWith(
|
||||
'access_token',
|
||||
'new.access',
|
||||
@@ -180,7 +195,11 @@ describe('AuthController', () => {
|
||||
});
|
||||
|
||||
it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => {
|
||||
auth.refresh.mockResolvedValue({ user: session.user, tokens: null });
|
||||
auth.refresh.mockResolvedValue({
|
||||
user: session.user,
|
||||
tokens: null,
|
||||
state: { restriction: 'twoFactorSetup', twoFactorDeadline: null },
|
||||
});
|
||||
const res = fakeResponse();
|
||||
|
||||
const user = await controller.refresh(
|
||||
@@ -189,7 +208,7 @@ describe('AuthController', () => {
|
||||
res as unknown as Response,
|
||||
);
|
||||
|
||||
expect(user).toEqual(session.user);
|
||||
expect(user).toEqual({ ...sessionBody, restriction: 'twoFactorSetup' });
|
||||
expect(res.cookie).not.toHaveBeenCalled();
|
||||
expect(res.clearCookie).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -278,4 +297,58 @@ describe('AuthController', () => {
|
||||
expect(auth.logout).toHaveBeenCalledWith('old.refresh');
|
||||
expect(res.clearCookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('제한된 세션도 /api/auth/* 를 부른다 — 컨트롤러에 @AllowRestrictedSession (13단계)', () => {
|
||||
expect(Reflect.getMetadata(ALLOW_RESTRICTED_SESSION, AuthController)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('me 는 서비스가 정한 세션 상태(세션 표의 지금 제한 · 정책으로 다시 본 유예)를 싣는다 (13단계)', async () => {
|
||||
auth.sessionStateOf.mockResolvedValueOnce({
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: '2026-11-30T15:00:00.000Z',
|
||||
});
|
||||
const user = {
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
};
|
||||
await expect(controller.me(user)).resolves.toEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: '2026-11-30T15:00:00.000Z',
|
||||
});
|
||||
expect(auth.sessionStateOf).toHaveBeenCalledWith(user);
|
||||
await expect(
|
||||
controller.me({ email: 'ara.kim@arasystem.test', name: '김아라' }),
|
||||
).resolves.toEqual(sessionBody);
|
||||
});
|
||||
|
||||
it('로그인 응답에 세션의 제한 · 유예를 싣는다 — 제한된 세션도 쿠키를 받는다 (13단계)', async () => {
|
||||
auth.login.mockResolvedValue({
|
||||
...session,
|
||||
state: {
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: '2026-11-30T15:00:00.000Z',
|
||||
},
|
||||
});
|
||||
const res = fakeResponse();
|
||||
|
||||
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(result).toEqual({
|
||||
status: 'signedIn',
|
||||
...session.user,
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: '2026-11-30T15:00:00.000Z',
|
||||
});
|
||||
expect(res.cookie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
@@ -43,8 +44,10 @@ import { LoginDto } from './dto/login.dto';
|
||||
/**
|
||||
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
|
||||
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
|
||||
* 13단계: `/api/auth/*` 는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다 — 로그인 · me · 갱신의 응답에 restriction · twoFactorDeadline 이 있다
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@AllowRestrictedSession()
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
@@ -95,23 +98,46 @@ export class AuthController {
|
||||
userAgent: req.get('user-agent') ?? null,
|
||||
});
|
||||
if (result.status === 'twoFactorRequired') {
|
||||
return { status: result.status, email: null, name: null };
|
||||
return {
|
||||
status: result.status,
|
||||
email: null,
|
||||
name: null,
|
||||
restriction: null,
|
||||
twoFactorDeadline: null,
|
||||
};
|
||||
}
|
||||
setSessionCookies(res, result.tokens, this.settings);
|
||||
return { status: result.status, ...result.user };
|
||||
return {
|
||||
status: result.status,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
restriction: result.state.restriction,
|
||||
twoFactorDeadline: result.state.twoFactorDeadline,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiCookieAuth()
|
||||
@ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' })
|
||||
@ApiOperation({
|
||||
summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)',
|
||||
description:
|
||||
'13단계: restriction 은 세션 표의 지금 값(제한을 푼 뒤의 옛 토큰으로도 마친 단계가 다시 보이지 않는다), twoFactorDeadline 은 정책과 켜짐으로 다시 본 값이다.',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '로그인 상태' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다',
|
||||
)
|
||||
me(@CurrentUser() user: AuthenticatedUser): AuthUserDto {
|
||||
return { email: user.email, name: user.name };
|
||||
async me(@CurrentUser() user: AuthenticatedUser): Promise<AuthUserDto> {
|
||||
// 제한은 세션 표의 지금 값, 유예는 정책으로 다시 본 값 (13단계 — 판단은 AuthService)
|
||||
const state = await this.auth.sessionStateOf(user);
|
||||
return {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
restriction: state.restriction,
|
||||
twoFactorDeadline: state.twoFactorDeadline,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +151,8 @@ export class AuthController {
|
||||
description:
|
||||
'액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' +
|
||||
'이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' +
|
||||
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).',
|
||||
'새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다). ' +
|
||||
'13단계: 제한(restriction)은 갱신 때 다시 정한다 — 비밀번호 만료는 엔진에서 다시 알아채고, 2단계 인증 필수는 정책과 켜짐(우리 기록 → 엔진)으로 본다.',
|
||||
})
|
||||
@ApiStandardResponse(AuthUserDto, { description: '회전 성공' })
|
||||
@ApiErrorResponse(
|
||||
@@ -145,7 +172,12 @@ export class AuthController {
|
||||
);
|
||||
// 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게
|
||||
if (result.tokens) setSessionCookies(res, result.tokens, this.settings);
|
||||
return result.user;
|
||||
return {
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
restriction: result.state.restriction,
|
||||
twoFactorDeadline: result.state.twoFactorDeadline,
|
||||
};
|
||||
} catch (error) {
|
||||
// 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
@@ -172,7 +204,8 @@ export class AuthController {
|
||||
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' +
|
||||
'12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' +
|
||||
'바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다.',
|
||||
'바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다. ' +
|
||||
'13단계: 비밀번호가 만료된 세션(restriction passwordChange)의 푸는 길이다 — 지금 비밀번호는 임시 비밀번호. 바뀌면 이 세션의 제한을 지운다(클라이언트는 곧바로 /auth/refresh).',
|
||||
})
|
||||
@ApiStandardResponse(undefined, {
|
||||
description: '바꿨다 (data: null)',
|
||||
|
||||
@@ -6,7 +6,12 @@ import type { AppPasswordsService } from '@/modules/account-security/app-passwor
|
||||
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 { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
PasswordExpiredException,
|
||||
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';
|
||||
@@ -100,6 +105,7 @@ class FakeSessions {
|
||||
expiresAt: command.nextExpiresAt,
|
||||
ip: command.ip,
|
||||
userAgent: command.userAgent,
|
||||
restriction: command.restriction,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(alive);
|
||||
@@ -115,6 +121,39 @@ class FakeSessions {
|
||||
},
|
||||
);
|
||||
|
||||
/** 실제 저장소와 같은 조건 — 지금 그 제한이고 끝나지 않았을 때만 끝낸다 (13단계 — 재리뷰 N2) */
|
||||
readonly revokeIfRestricted = jest.fn(
|
||||
(
|
||||
id: string,
|
||||
restriction: AuthSessionEntity['restriction'],
|
||||
reason: AuthSessionEntity['revokedReason'],
|
||||
now: Date,
|
||||
) => {
|
||||
const row = this.rows.get(id);
|
||||
if (row && row.restriction === restriction && row.revokedAt === null) {
|
||||
Object.assign(row, { revokedAt: now, revokedReason: reason });
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
);
|
||||
|
||||
/** 실제 저장소와 같은 조건 — 지금 `from` 일 때만 바꾼다 (13단계) */
|
||||
readonly replaceRestriction = jest.fn(
|
||||
(
|
||||
id: string,
|
||||
from: AuthSessionEntity['restriction'],
|
||||
to: AuthSessionEntity['restriction'],
|
||||
) => {
|
||||
const row = this.rows.get(id);
|
||||
if (row && row.restriction === from) {
|
||||
row.restriction = to;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
);
|
||||
|
||||
/** 실제 저장소와 같은 조건 — 그 직원의 살아 있는 세션 가운데 keepId 를 뺀 것만 끝낸다 */
|
||||
readonly revokeAllOf = jest.fn(
|
||||
(
|
||||
@@ -215,6 +254,11 @@ describe('AuthService', () => {
|
||||
let twoFactorLocks: TwoFactorLocks;
|
||||
let loginEvents: { recordWebmail: jest.Mock };
|
||||
let appPasswords: { removeAllAfterPasswordChange: jest.Mock };
|
||||
/** 2단계 인증 필수 정책 대역 (13단계) — 처음은 제한 · 유예 없음 */
|
||||
let twoFactorPolicy: {
|
||||
sessionState: jest.Mock;
|
||||
isEnabledInEngine: jest.Mock;
|
||||
};
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -232,6 +276,7 @@ describe('AuthService', () => {
|
||||
twoFactorLocks,
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
twoFactorPolicy as unknown as TwoFactorPolicyService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -278,6 +323,12 @@ describe('AuthService', () => {
|
||||
appPasswords = {
|
||||
removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
twoFactorPolicy = {
|
||||
sessionState: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ restriction: null, deadline: null }),
|
||||
isEnabledInEngine: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -1733,4 +1784,380 @@ describe('AuthService', () => {
|
||||
expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('13단계 — 제한된 세션 (계약 6절)', () => {
|
||||
const expired = () => new PasswordExpiredException(ARA, 'ara.kim');
|
||||
const DEADLINE = new Date('2026-11-30T15:00:00.000Z');
|
||||
|
||||
it('비밀번호가 만료된 계정은 로그인하되 passwordChange 제한 — 쿠키를 받고 세션 · 액세스 토큰에 제한이 실린다', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
expect(result.state).toEqual({
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
expect(result.user).toEqual({ email: ARA, name: 'ara.kim' });
|
||||
expect(sessions.only().restriction).toBe('passwordChange');
|
||||
expect(decode(result.tokens.accessToken).rst).toBe('passwordChange');
|
||||
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: ARA, isSuccess: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('2단계 인증을 켠 직원도 코드 뒤에 만료를 알아챈다 — 402 의 정식 주소를 읽을 때 만료 예외로 정식 주소를 얻는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
mail.getProfile.mockRejectedValue(expired());
|
||||
|
||||
const first = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
expect(first.status).toBe('twoFactorRequired');
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP, {
|
||||
otpCode: '123456',
|
||||
});
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
expect(result.state.restriction).toBe('passwordChange');
|
||||
// 켜짐은 엔진의 답(402)으로 안다
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true);
|
||||
});
|
||||
|
||||
it('2단계 인증 필수가 시작됐고 꺼져 있으면 twoFactorSetup — 켜짐은 엔진의 답(200 = 꺼짐)으로 넘긴다', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenCalledWith(ARA, false);
|
||||
expect(result.state.restriction).toBe('twoFactorSetup');
|
||||
expect(sessions.only().restriction).toBe('twoFactorSetup');
|
||||
expect(directory.recordLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호 만료가 2단계 필수보다 먼저다 — 유예의 시작 시각은 함께 싣는다', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: null,
|
||||
deadline: DEADLINE,
|
||||
});
|
||||
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
expect(result.state).toEqual({
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: DEADLINE.toISOString(),
|
||||
});
|
||||
const access = decode(result.tokens.accessToken);
|
||||
expect(access.rst).toBe('passwordChange');
|
||||
expect(access.tfd).toBe(DEADLINE.toISOString());
|
||||
});
|
||||
|
||||
it('제한이 없으면 액세스 토큰에 rst · tfd 가 없다', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const access = decode(result.tokens.accessToken);
|
||||
expect(access).not.toHaveProperty('rst');
|
||||
expect(access).not.toHaveProperty('tfd');
|
||||
expect(sessions.only().restriction).toBeNull();
|
||||
});
|
||||
|
||||
it('갱신 때 제한을 다시 정한다 — 엔진에서 만료를 다시 알아채고, 풀렸으면 정책(켜짐은 우리 기록 → 엔진)으로', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
|
||||
// 아직 바꾸지 않았다 — 갱신해도 passwordChange (세션은 끊지 않는다)
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
const stillExpired = await service.refresh(login.tokens.refreshToken);
|
||||
expect(stillExpired.state.restriction).toBe('passwordChange');
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
expect(sessions.only().restriction).toBe('passwordChange');
|
||||
|
||||
// 바꿨다 — 엔진의 만료가 풀렸고 필수 정책이 남았다
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const next = await service.refresh(stillExpired.tokens!.refreshToken);
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
undefined,
|
||||
expect.any(Date),
|
||||
);
|
||||
expect(next.state.restriction).toBe('twoFactorSetup');
|
||||
expect(sessions.only().restriction).toBe('twoFactorSetup');
|
||||
expect(decode(next.tokens!.accessToken).rst).toBe('twoFactorSetup');
|
||||
expect(next.user.name).toBe('김아라');
|
||||
});
|
||||
|
||||
it('동시 갱신 유예의 응답은 먼저 회전한 요청이 세션 표에 적은 제한을 준다', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const token = login.tokens.refreshToken;
|
||||
await service.refresh(token);
|
||||
|
||||
const grace = await service.refresh(token);
|
||||
expect(grace.tokens).toBeNull();
|
||||
expect(grace.state.restriction).toBe('twoFactorSetup');
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾸면 이 요청 세션(액세스 토큰의 sid)의 passwordChange 제한을 지운다 — 다음 제한이 없으면 null', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const sessionId = sessions.only().id;
|
||||
expect(sessions.only().restriction).toBe('passwordChange');
|
||||
|
||||
await service.changePassword(
|
||||
{ email: ARA, name: 'ara.kim', sessionId },
|
||||
PASSWORD,
|
||||
'new-password-123',
|
||||
CLIENT_IP,
|
||||
login.tokens.refreshToken,
|
||||
);
|
||||
|
||||
expect(sessions.replaceRestriction).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
'passwordChange',
|
||||
null,
|
||||
);
|
||||
// 2단계는 꺼져 있다(엔진의 답 200) — 다음 제한은 정책과 그 값으로
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, false);
|
||||
expect(sessions.only().restriction).toBeNull();
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('다음 제한이 남았으면 지우지 않고 그 제한으로 바꾼다 — 2단계 필수가 시작됐고 꺼져 있으면 twoFactorSetup (리뷰 M2)', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const sessionId = sessions.only().id;
|
||||
// 비밀번호 만료가 먼저다
|
||||
expect(sessions.only().restriction).toBe('passwordChange');
|
||||
|
||||
await service.changePassword(
|
||||
{ email: ARA, name: 'ara.kim', sessionId },
|
||||
PASSWORD,
|
||||
'new-password-123',
|
||||
CLIENT_IP,
|
||||
login.tokens.refreshToken,
|
||||
);
|
||||
|
||||
expect(sessions.only().restriction).toBe('twoFactorSetup');
|
||||
});
|
||||
|
||||
it('2단계를 켠 직원(402 길)의 비밀번호 바꾸기는 켜짐으로 다음 제한을 정한다', async () => {
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const sessionId = sessions.only().id;
|
||||
sessions.rows.get(sessionId)!.restriction = 'passwordChange';
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
twoFactorPolicy.sessionState.mockClear();
|
||||
|
||||
await service.changePassword(
|
||||
{ email: ARA, name: 'ara.kim', sessionId },
|
||||
PASSWORD,
|
||||
'new-password-123',
|
||||
CLIENT_IP,
|
||||
login.tokens.refreshToken,
|
||||
'123456',
|
||||
);
|
||||
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true);
|
||||
expect(sessions.only().restriction).toBeNull();
|
||||
});
|
||||
|
||||
it('제한 없는 세션의 보통 비밀번호 바꾸기는 제한을 붙이지 않는다 — 지금 passwordChange 일 때만 바꾼다', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: null,
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
|
||||
await service.changePassword(
|
||||
{ email: ARA, name: '김아라', sessionId: sessions.only().id },
|
||||
PASSWORD,
|
||||
'new-password-123',
|
||||
CLIENT_IP,
|
||||
login.tokens.refreshToken,
|
||||
);
|
||||
expect(sessions.only().restriction).toBeNull();
|
||||
});
|
||||
|
||||
it('갱신 — 세션 표가 twoFactorSetup 인데 계정이 이미 켜짐이면 제한을 지우지 않고 세션을 끊는다(AUTH_001) (리뷰 M1)', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
expect(sessions.only().restriction).toBe('twoFactorSetup');
|
||||
|
||||
// 다른 세션에서 2단계를 켰다 — 엔진에 묻는다(우리 기록이 아니라)
|
||||
twoFactorPolicy.isEnabledInEngine.mockResolvedValueOnce(true);
|
||||
await expect(
|
||||
service.refresh(login.tokens.refreshToken),
|
||||
).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
expect(sessions.only()).toMatchObject({
|
||||
restriction: 'twoFactorSetup',
|
||||
revokedReason: 'two-factor-not-verified',
|
||||
});
|
||||
expect(sessions.only().revokedAt).not.toBeNull();
|
||||
expect(logs.join('\n')).toContain(
|
||||
'2단계 인증을 거치지 않은 세션을 끊었습니다',
|
||||
);
|
||||
});
|
||||
|
||||
it('갱신 — 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않고 보통 회전으로 이어 간다 (재리뷰 N2)', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const sessionId = sessions.only().id;
|
||||
|
||||
// 갱신이 엔진에 묻는 사이 이 세션이 켜기를 마쳐 제한을 지웠다
|
||||
twoFactorPolicy.isEnabledInEngine.mockImplementationOnce(() => {
|
||||
sessions.rows.get(sessionId)!.restriction = null;
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: null,
|
||||
deadline: null,
|
||||
});
|
||||
const next = await service.refresh(login.tokens.refreshToken);
|
||||
expect(next.tokens).not.toBeNull();
|
||||
expect(next.state.restriction).toBeNull();
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
// 켜짐은 이미 알았다 — 정책에 그 값을 넘긴다
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
true,
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('로그인 — 잠긴 계정(2단계를 켜 엔진이 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 · 실패로 센다 (실사용 시험)', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
mail.getProfile.mockRejectedValue(new AccountLoginDisabledException(ARA));
|
||||
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP),
|
||||
).rejects.toMatchObject({ code: 'AUTH_004' });
|
||||
await expect(
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: '123456' }),
|
||||
).rejects.toMatchObject({ code: 'AUTH_004' });
|
||||
// 틀린 비밀번호와 같은 기록
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
}),
|
||||
);
|
||||
expect(sessions.rows.size).toBe(0);
|
||||
});
|
||||
|
||||
it('갱신 — 세션 표가 twoFactorSetup 이고 아직 꺼져 있으면 그대로 정책으로(켜짐을 한 번만 묻는다)', async () => {
|
||||
twoFactorPolicy.sessionState.mockResolvedValue({
|
||||
restriction: 'twoFactorSetup',
|
||||
deadline: null,
|
||||
});
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
|
||||
const next = await service.refresh(login.tokens.refreshToken);
|
||||
expect(next.state.restriction).toBe('twoFactorSetup');
|
||||
expect(twoFactorPolicy.isEnabledInEngine).toHaveBeenCalledTimes(1);
|
||||
expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
false,
|
||||
expect.any(Date),
|
||||
);
|
||||
});
|
||||
|
||||
it('me 의 상태 — 제한은 세션 표의 지금 값(푼 뒤 옛 토큰이어도 null), 유예는 정책으로 다시 본다', async () => {
|
||||
mail.getProfile.mockRejectedValueOnce(expired());
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const sessionId = sessions.only().id;
|
||||
const tokenUser = {
|
||||
email: ARA,
|
||||
name: 'ara.kim',
|
||||
sessionId,
|
||||
restriction: 'passwordChange' as const,
|
||||
twoFactorDeadline: null,
|
||||
};
|
||||
|
||||
await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
|
||||
// 비밀번호를 바꿔 세션의 제한이 지워졌다 — 옛 토큰(rst 그대로)으로 물어도 제한 없음
|
||||
sessions.rows.get(sessionId)!.restriction = null;
|
||||
twoFactorPolicy.sessionState.mockResolvedValueOnce({
|
||||
restriction: null,
|
||||
deadline: DEADLINE,
|
||||
});
|
||||
await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({
|
||||
restriction: null,
|
||||
twoFactorDeadline: DEADLINE.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('me 의 상태 — 정책을 읽지 못하면 토큰에 실린 유예를 그대로, 세션 id 가 없는 옛 토큰은 토큰의 제한', async () => {
|
||||
twoFactorPolicy.sessionState.mockRejectedValueOnce(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(
|
||||
service.sessionStateOf({
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
twoFactorDeadline: DEADLINE.toISOString(),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
restriction: null,
|
||||
twoFactorDeadline: DEADLINE.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('제한 바꾸기가 실패해도 비밀번호 바꾸기는 성공이다 — error 로그만', async () => {
|
||||
const login = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (login.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
sessions.replaceRestriction.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.changePassword(
|
||||
{ email: ARA, name: '김아라' },
|
||||
PASSWORD,
|
||||
'new-password-123',
|
||||
CLIENT_IP,
|
||||
login.tokens.refreshToken,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(logs.join('\n')).toContain('세션의 제한을 바꾸지 못했습니다');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isAppPasswordShape } from '@/modules/account-security/app-password';
|
||||
@@ -15,7 +16,13 @@ import {
|
||||
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 { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
MAIL_PROVIDER,
|
||||
PasswordExpiredException,
|
||||
type MailProvider,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
MailAccountProfile,
|
||||
@@ -50,6 +57,15 @@ import {
|
||||
type LoginAttemptTicket,
|
||||
} from './login-attempt.service';
|
||||
|
||||
/**
|
||||
* 세션의 제한 · 2단계 인증 필수의 유예 (13단계 — 계약 6절 "제한된 세션") — 로그인 · 갱신 · me 의 응답과 액세스 토큰에 싣는다
|
||||
*/
|
||||
export interface SessionState {
|
||||
readonly restriction: SessionRestriction | null;
|
||||
/** 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null */
|
||||
readonly twoFactorDeadline: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
readonly user: AuthenticatedUser;
|
||||
/**
|
||||
@@ -57,6 +73,8 @@ export interface AuthResult {
|
||||
* 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다.
|
||||
*/
|
||||
readonly tokens: SessionTokens | null;
|
||||
/** 세션의 제한 · 유예 (13단계) */
|
||||
readonly state: SessionState;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +85,8 @@ export type LoginResult =
|
||||
readonly status: 'signedIn';
|
||||
readonly user: AuthenticatedUser;
|
||||
readonly tokens: SessionTokens;
|
||||
/** 세션의 제한 · 유예 (13단계 — 제한된 세션도 쿠키를 받는다) */
|
||||
readonly state: SessionState;
|
||||
}
|
||||
| {
|
||||
readonly status: 'twoFactorRequired';
|
||||
@@ -89,6 +109,13 @@ export interface LoginOptions {
|
||||
readonly userAgent?: string | null;
|
||||
}
|
||||
|
||||
/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */
|
||||
interface LoadedProfile {
|
||||
readonly profile: MailAccountProfile;
|
||||
/** 엔진의 비밀번호가 만료됐다 — 세션에 passwordChange 제한을 붙인다 */
|
||||
readonly passwordExpired: boolean;
|
||||
}
|
||||
|
||||
/** 리프레시 토큰에서 꺼낸 값 */
|
||||
interface RefreshClaims {
|
||||
readonly sub: string;
|
||||
@@ -103,6 +130,14 @@ function toUser(profile: MailAccountProfile): AuthenticatedUser {
|
||||
return { email: profile.email, name: profile.name };
|
||||
}
|
||||
|
||||
/** 비밀번호가 만료된 계정의 프로필 — 엔진이 알려 준 정식 주소와 메일 기능 없이 쓸 이름 (13단계) */
|
||||
function expiredProfile(error: PasswordExpiredException): LoadedProfile {
|
||||
return {
|
||||
profile: { email: error.account, name: error.displayName },
|
||||
passwordExpired: true,
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -146,6 +181,8 @@ export class AuthService {
|
||||
private readonly twoFactorLocks: TwoFactorLocks,
|
||||
private readonly loginEvents: LoginEventsService,
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
/** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */
|
||||
private readonly twoFactorPolicy: TwoFactorPolicyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -193,6 +230,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
let profile: MailAccountProfile;
|
||||
let passwordExpired = false;
|
||||
let isTwoFactor = false;
|
||||
try {
|
||||
if (!loginAddress) {
|
||||
@@ -211,10 +249,18 @@ export class AuthService {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
if (check.kind === 'valid') {
|
||||
profile = await this.profileOf(check.account, address);
|
||||
({ profile, passwordExpired } = await this.profileOf(
|
||||
check.account,
|
||||
address,
|
||||
client,
|
||||
));
|
||||
} else {
|
||||
// 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다)
|
||||
profile = await this.profileOf(address, address);
|
||||
// 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정 · 잠긴 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다)
|
||||
({ profile, passwordExpired } = await this.profileOf(
|
||||
address,
|
||||
address,
|
||||
client,
|
||||
));
|
||||
// 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
|
||||
ticket.verified();
|
||||
if (options.otpCode == null) {
|
||||
@@ -242,8 +288,18 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const tokens = await this.startSession(profile, client);
|
||||
await this.recordInDirectory(profile);
|
||||
// 제한(13단계): 비밀번호 만료가 먼저, 그다음 2단계 인증 필수 — 켜짐은 엔진의 답(402)으로 안다
|
||||
const twoFactor = await this.twoFactorPolicy.sessionState(
|
||||
normalizeEmail(profile.email),
|
||||
isTwoFactor,
|
||||
);
|
||||
const state: SessionState = {
|
||||
restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction,
|
||||
twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null,
|
||||
};
|
||||
const tokens = await this.startSession(profile, client, state);
|
||||
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
|
||||
if (!passwordExpired) await this.recordInDirectory(profile);
|
||||
await this.loginEvents.recordWebmail({
|
||||
email: profile.email,
|
||||
isSuccess: true,
|
||||
@@ -253,9 +309,9 @@ export class AuthService {
|
||||
userAgent: client.userAgent ?? null,
|
||||
});
|
||||
this.logger.log(
|
||||
`로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}`,
|
||||
`로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`,
|
||||
);
|
||||
return { status: 'signedIn', user: toUser(profile), tokens };
|
||||
return { status: 'signedIn', user: toUser(profile), tokens, state };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,15 +351,16 @@ export class AuthService {
|
||||
private async profileOf(
|
||||
account: string,
|
||||
address: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
client: ClientInfo,
|
||||
): Promise<LoadedProfile> {
|
||||
if (!this.isLoginAddress(account)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
const profile = await this.loadProfileForLogin(account, address);
|
||||
if (!this.isLoginAddress(profile.email)) {
|
||||
const loaded = await this.loadProfileForLogin(account, address, client);
|
||||
if (!this.isLoginAddress(loaded.profile.email)) {
|
||||
throw this.loginFailed(address, 'rejected');
|
||||
}
|
||||
return profile;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */
|
||||
@@ -435,6 +492,8 @@ export class AuthService {
|
||||
let releaseLock: (() => void) | null = null;
|
||||
try {
|
||||
let outcome: PasswordChangeOutcome;
|
||||
// 2단계 인증이 켜졌는가 — 402 길이면 켜짐 (바꾼 뒤 다음 제한 twoFactorSetup 을 정할 때 쓴다)
|
||||
let twoFactorEnabled = false;
|
||||
try {
|
||||
const check = await this.mail.verifyCredentials(
|
||||
address,
|
||||
@@ -450,6 +509,7 @@ export class AuthService {
|
||||
);
|
||||
} else {
|
||||
// 402 — 비밀번호는 맞았다 (코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다)
|
||||
twoFactorEnabled = true;
|
||||
ticket.verified();
|
||||
if (otpCode == null) {
|
||||
throw this.otpCodeRequired(address);
|
||||
@@ -477,6 +537,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
await this.settlePasswordChange(outcome, ticket, address, refreshToken);
|
||||
await this.advancePasswordChangeRestriction(
|
||||
user,
|
||||
address,
|
||||
refreshToken,
|
||||
twoFactorEnabled,
|
||||
);
|
||||
} finally {
|
||||
releaseLock?.();
|
||||
}
|
||||
@@ -585,6 +651,38 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꿨다 — **이 요청의 세션**(액세스 토큰의 sid, 없으면 리프레시 쿠키)의 passwordChange 제한을 다음 제한으로 바꾼다
|
||||
* (13단계 — 계약 6절 "제한된 세션": 2단계 인증 필수가 시작됐고 아직 켜지 않았으면 twoFactorSetup, 아니면 null — 옛 토큰으로도 다음 단계 밖은 막힌다).
|
||||
* 엔진은 바꾸는 순간 만료를 스스로 푼다. 지금 passwordChange 일 때만 바꾼다(조건부 UPDATE — 제한 없는 세션의 보통 바꾸기는 건드리지 않는다).
|
||||
* 실패해도 던지지 않는다 — 비밀번호는 바뀌었고, 다음 갱신이 엔진 · 정책으로 다시 정한다
|
||||
*/
|
||||
private async advancePasswordChangeRestriction(
|
||||
user: AuthenticatedUser,
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
twoFactorEnabled: boolean,
|
||||
): Promise<void> {
|
||||
const sessionId =
|
||||
user.sessionId ?? (await this.currentSessionId(refreshToken, address));
|
||||
if (sessionId === null) return;
|
||||
try {
|
||||
const next = await this.twoFactorPolicy.sessionState(
|
||||
address,
|
||||
twoFactorEnabled,
|
||||
);
|
||||
await this.sessions.replaceRestriction(
|
||||
sessionId,
|
||||
'passwordChange',
|
||||
next.restriction,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호를 바꿨지만 세션의 제한을 바꾸지 못했습니다 — 다음 갱신이 다시 정합니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private currentPasswordRejected(
|
||||
address: string,
|
||||
reason: string,
|
||||
@@ -647,6 +745,35 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* me 의 세션 상태 (13단계 — 계약 6절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게),
|
||||
* 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다.
|
||||
* 읽지 못하면 액세스 토큰의 값을 쓴다 (앱 시작이 막히지 않게)
|
||||
*/
|
||||
async sessionStateOf(user: AuthenticatedUser): Promise<SessionState> {
|
||||
let restriction = user.restriction ?? null;
|
||||
if (user.sessionId) {
|
||||
try {
|
||||
const row = await this.sessions.findById(user.sessionId);
|
||||
restriction = row?.restriction ?? null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 세션의 제한을 읽지 못해 토큰의 값으로 답합니다: user=${maskEmail(user.email)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
let twoFactorDeadline = user.twoFactorDeadline ?? null;
|
||||
try {
|
||||
const twoFactor = await this.twoFactorPolicy.sessionState(
|
||||
normalizeEmail(user.email),
|
||||
);
|
||||
twoFactorDeadline = twoFactor.deadline?.toISOString() ?? null;
|
||||
} catch {
|
||||
// 정책 · 엔진을 읽지 못했다 — 토큰에 실린 값(로그인 · 갱신 때 정한 것)으로 답한다
|
||||
}
|
||||
return { restriction, twoFactorDeadline };
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
@@ -712,8 +839,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (state === 'grace') {
|
||||
const profile = await this.mail.getProfile(claims.sub);
|
||||
return { user: toUser(profile), tokens: null };
|
||||
const { profile } = await this.profileForGrace(claims.sub);
|
||||
return {
|
||||
user: toUser(profile),
|
||||
tokens: null,
|
||||
state: await this.graceState(session, profile.email),
|
||||
};
|
||||
}
|
||||
|
||||
if (state === 'reused') {
|
||||
@@ -735,13 +866,58 @@ export class AuthService {
|
||||
now: Date,
|
||||
client: ClientInfo,
|
||||
): Promise<AuthResult> {
|
||||
const profile = await this.loadProfileForRefresh(claims, now);
|
||||
const { profile, passwordExpired } = await this.loadProfileForRefresh(
|
||||
claims,
|
||||
now,
|
||||
);
|
||||
const address = normalizeEmail(profile.email);
|
||||
// 2단계를 거친 적이 없는 세션(twoFactorSetup)인데 계정은 이미 켜짐 — 다른 세션에서 켰다. 제한을 지우지 않고 끊는다 (13단계 — 계약 6절).
|
||||
// 켜짐은 엔진에 묻는다(우리 기록이 오래됐으면 갱신마다 끊기므로 — 재리뷰 N1). 끊기는 조건부 — 그사이 이 세션이 켜 제한이 지워졌으면 보통 회전으로(N2)
|
||||
let knownEnabled: boolean | undefined;
|
||||
if (session.restriction === 'twoFactorSetup') {
|
||||
knownEnabled = await this.twoFactorPolicy.isEnabledInEngine(address);
|
||||
if (
|
||||
knownEnabled &&
|
||||
(await this.sessions.revokeIfRestricted(
|
||||
claims.sid,
|
||||
'twoFactorSetup',
|
||||
'two-factor-not-verified',
|
||||
now,
|
||||
))
|
||||
) {
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 2단계 인증을 거치지 않은 세션을 끊었습니다 — 그사이 계정에 2단계가 켜졌습니다: user=${maskEmail(address)}, sid=${claims.sid}`,
|
||||
);
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'session-two-factor-not-verified',
|
||||
sid: claims.sid,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// 제한은 갱신 때 다시 정한다(13단계): 비밀번호 만료(엔진에서 다시 알아챈다)가 먼저, 그다음 정책 — 켜짐은 우리 기록 → 엔진
|
||||
const twoFactor = await this.twoFactorPolicy.sessionState(
|
||||
address,
|
||||
knownEnabled,
|
||||
now,
|
||||
);
|
||||
const sessionState: SessionState = {
|
||||
restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction,
|
||||
twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null,
|
||||
};
|
||||
const expiresAt = nextIdleExpiry(
|
||||
now,
|
||||
this.settings.refreshTtlSeconds,
|
||||
session.absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, claims.sid, expiresAt, now);
|
||||
const tokens = await this.signTokens(
|
||||
profile,
|
||||
claims.sid,
|
||||
expiresAt,
|
||||
now,
|
||||
sessionState,
|
||||
);
|
||||
|
||||
const rotated = await this.sessions.rotate({
|
||||
id: claims.sid,
|
||||
@@ -751,13 +927,18 @@ export class AuthService {
|
||||
now,
|
||||
ip: client.ip || null,
|
||||
userAgent: truncateUserAgent(client.userAgent),
|
||||
restriction: sessionState.restriction,
|
||||
});
|
||||
if (rotated) return { user: toUser(profile), tokens };
|
||||
if (rotated) {
|
||||
return { user: toUser(profile), tokens, state: sessionState };
|
||||
}
|
||||
|
||||
// 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다
|
||||
const latest = await this.sessions.findById(claims.sid);
|
||||
const state = this.classify(latest, claims, presentedHash, now);
|
||||
if (state === 'grace') return { user: toUser(profile), tokens: null };
|
||||
if (state === 'grace') {
|
||||
return { user: toUser(profile), tokens: null, state: sessionState };
|
||||
}
|
||||
if (state === 'current') {
|
||||
// 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다.
|
||||
// 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고,
|
||||
@@ -780,10 +961,17 @@ export class AuthService {
|
||||
private async loadProfileForRefresh(
|
||||
claims: RefreshClaims,
|
||||
now: Date,
|
||||
): Promise<MailAccountProfile> {
|
||||
): Promise<LoadedProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(claims.sub, { fresh: true });
|
||||
return {
|
||||
profile: await this.mail.getProfile(claims.sub, { fresh: true }),
|
||||
passwordExpired: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// 비밀번호가 만료됐다(관리자가 재설정했거나 아직 바꾸지 않았다) — 세션은 남기고 passwordChange 제한을 붙인다 (13단계)
|
||||
if (error instanceof PasswordExpiredException) {
|
||||
return expiredProfile(error);
|
||||
}
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
await this.sessions.revoke(claims.sid, 'account-unavailable', now);
|
||||
}
|
||||
@@ -795,10 +983,24 @@ export class AuthService {
|
||||
private async loadProfileForLogin(
|
||||
account: string,
|
||||
address: string,
|
||||
): Promise<MailAccountProfile> {
|
||||
client: ClientInfo,
|
||||
): Promise<LoadedProfile> {
|
||||
try {
|
||||
return await this.mail.getProfile(account, { fresh: true });
|
||||
return {
|
||||
profile: await this.mail.getProfile(account, { fresh: true }),
|
||||
passwordExpired: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// 비밀번호는 맞았고 엔진의 비밀번호가 만료됐다 — 로그인은 하되 새 비밀번호 단계로 보낸다 (13단계 — 계약 6절)
|
||||
if (error instanceof PasswordExpiredException) {
|
||||
return expiredProfile(error);
|
||||
}
|
||||
if (error instanceof AccountLoginDisabledException) {
|
||||
// 잠긴 계정 — 2단계를 켰으면 엔진이 2단계를 먼저 봐 비밀번호가 맞을 때만 여기 온다. 틀린 비밀번호와 같은 AUTH_004 · 기록 · 실패 한도로
|
||||
// (잠긴 · 털린 계정의 비밀번호가 맞는지 바깥에서 알 수 없게 — 2단계가 꺼진 잠긴 계정은 엔진이 401 로 답해 이미 그렇다)
|
||||
await this.recordLoginFailure(address, client, 'wrongPassword');
|
||||
throw this.loginFailed(address, 'account-login-disabled');
|
||||
}
|
||||
if (error instanceof BusinessException && error.code === 'AUTH_001') {
|
||||
throw this.loginFailed(address, 'mailbox-unavailable');
|
||||
}
|
||||
@@ -810,6 +1012,7 @@ export class AuthService {
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
client: ClientInfo,
|
||||
state: SessionState,
|
||||
): Promise<SessionTokens> {
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
@@ -821,7 +1024,7 @@ export class AuthService {
|
||||
this.settings.refreshTtlSeconds,
|
||||
absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, id, expiresAt, now);
|
||||
const tokens = await this.signTokens(profile, id, expiresAt, now, state);
|
||||
|
||||
await this.sessions.create({
|
||||
id,
|
||||
@@ -836,6 +1039,7 @@ export class AuthService {
|
||||
revokedReason: null,
|
||||
ip: client.ip || null,
|
||||
userAgent: truncateUserAgent(client.userAgent),
|
||||
restriction: state.restriction,
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
@@ -884,6 +1088,7 @@ export class AuthService {
|
||||
sessionId: string,
|
||||
refreshExpiresAt: Date,
|
||||
now: Date,
|
||||
state: SessionState,
|
||||
): Promise<SessionTokens> {
|
||||
const { jwtSecret, accessTtlSeconds } = this.settings;
|
||||
const refreshTtlSeconds = Math.max(
|
||||
@@ -891,11 +1096,16 @@ export class AuthService {
|
||||
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
|
||||
);
|
||||
// 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저")
|
||||
// 제한 · 유예(13단계)도 싣는다 — 가드가 제한을 보고 me 가 그대로 돌려준다 (있을 때만)
|
||||
const access: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'access',
|
||||
name: profile.name,
|
||||
sid: sessionId,
|
||||
...(state.restriction !== null ? { rst: state.restriction } : {}),
|
||||
...(state.twoFactorDeadline !== null
|
||||
? { tfd: state.twoFactorDeadline }
|
||||
: {}),
|
||||
};
|
||||
const refresh: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
@@ -921,6 +1131,43 @@ export class AuthService {
|
||||
return { accessToken, refreshToken, refreshTtlSeconds };
|
||||
}
|
||||
|
||||
/** 동시 갱신 유예의 프로필 — 기억한 세션으로 읽는다. 비밀번호가 만료된 계정은 주소의 @ 앞을 이름으로 (13단계) */
|
||||
private async profileForGrace(email: string): Promise<LoadedProfile> {
|
||||
try {
|
||||
return {
|
||||
profile: await this.mail.getProfile(email),
|
||||
passwordExpired: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PasswordExpiredException) {
|
||||
return expiredProfile(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 갱신 유예의 상태 — 제한은 먼저 회전한 요청이 세션 표에 적은 값, 유예의 시작 시각은 정책으로 (13단계).
|
||||
* 정책을 읽지 못하면 유예 없이 답한다 — 이긴 요청의 응답이 이미 같은 값을 주었다
|
||||
*/
|
||||
private async graceState(
|
||||
session: AuthSessionEntity | null,
|
||||
email: string,
|
||||
): Promise<SessionState> {
|
||||
const restriction = session?.restriction ?? null;
|
||||
try {
|
||||
const twoFactor = await this.twoFactorPolicy.sessionState(
|
||||
normalizeEmail(email),
|
||||
);
|
||||
return {
|
||||
restriction,
|
||||
twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { restriction, twoFactorDeadline: null };
|
||||
}
|
||||
}
|
||||
|
||||
private loginFailed(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_004', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
SESSION_RESTRICTIONS,
|
||||
type SessionRestriction,
|
||||
} from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
|
||||
/** 제한 칸의 Swagger 설명 — 로그인 · me · 갱신이 같은 글을 쓴다 */
|
||||
export const RESTRICTION_PROPERTY = {
|
||||
description:
|
||||
'제한된 세션 (13단계 — 계약 6절) — passwordChange(새 비밀번호를 정할 때까지) · twoFactorSetup(2단계 인증을 켤 때까지) · null(제한 없음). ' +
|
||||
'제한된 동안 다른 API 는 403 AUTH_010 이다',
|
||||
enum: SESSION_RESTRICTIONS,
|
||||
nullable: true,
|
||||
example: null,
|
||||
} as const;
|
||||
|
||||
/** 유예 칸의 Swagger 설명 */
|
||||
export const TWO_FACTOR_DEADLINE_PROPERTY = {
|
||||
description:
|
||||
'2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null. 화면은 메일함 위에 그 1ms 앞 날짜(시작일 전날)까지로 안내한다',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: null,
|
||||
} as const;
|
||||
|
||||
/** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */
|
||||
export class AuthUserDto implements AuthenticatedUser {
|
||||
@ApiProperty({
|
||||
@@ -12,4 +36,10 @@ export class AuthUserDto implements AuthenticatedUser {
|
||||
|
||||
@ApiProperty({ description: '표시 이름', example: '김아라' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty(RESTRICTION_PROPERTY)
|
||||
restriction!: SessionRestriction | null;
|
||||
|
||||
@ApiProperty(TWO_FACTOR_DEADLINE_PROPERTY)
|
||||
twoFactorDeadline!: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
RESTRICTION_PROPERTY,
|
||||
TWO_FACTOR_DEADLINE_PROPERTY,
|
||||
} from './auth-user.dto';
|
||||
|
||||
/**
|
||||
* POST /api/auth/login 의 응답 (계약 5절 "로그인") — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다.
|
||||
* twoFactorRequired 는 비밀번호가 맞았다는 뜻이다 — 쿠키가 없고 email · name 은 null (코드와 비밀번호를 함께 다시 보낸다)
|
||||
@@ -28,4 +34,16 @@ export class LoginResultDto {
|
||||
example: '김아라',
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
...RESTRICTION_PROPERTY,
|
||||
description: `${RESTRICTION_PROPERTY.description}. twoFactorRequired 면 null — 제한된 세션도 쿠키를 받는다`,
|
||||
})
|
||||
restriction!: SessionRestriction | null;
|
||||
|
||||
@ApiProperty({
|
||||
...TWO_FACTOR_DEADLINE_PROPERTY,
|
||||
description: `${TWO_FACTOR_DEADLINE_PROPERTY.description}. twoFactorRequired 면 null`,
|
||||
})
|
||||
twoFactorDeadline!: string | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import type { AuthSessionRepository } from './auth-session.repository';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import type { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
@@ -8,6 +10,14 @@ const revoked = {
|
||||
isRevoked: (sessionId: string) => revokedIds.has(sessionId),
|
||||
} as unknown as RevokedSessions;
|
||||
|
||||
/** 세션 표 대역 — 제한이 실린 토큰만 읽는다 (13단계) */
|
||||
const rows = new Map<string, Partial<AuthSessionEntity>>();
|
||||
const sessions = {
|
||||
findById: jest.fn((id: string) =>
|
||||
Promise.resolve((rows.get(id) as AuthSessionEntity | undefined) ?? null),
|
||||
),
|
||||
};
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
{
|
||||
jwtSecret: 'unit-test-secret-unit-test-secret-1234',
|
||||
@@ -16,11 +26,16 @@ const strategy = new JwtStrategy(
|
||||
secureCookies: false,
|
||||
},
|
||||
revoked,
|
||||
sessions as unknown as AuthSessionRepository,
|
||||
);
|
||||
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
const SID = '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a';
|
||||
|
||||
async function codeOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
run();
|
||||
await run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
@@ -28,20 +43,24 @@ function codeOf(run: () => unknown): string | undefined {
|
||||
}
|
||||
|
||||
describe('JwtStrategy.validate', () => {
|
||||
afterEach(() => revokedIds.clear());
|
||||
afterEach(() => {
|
||||
revokedIds.clear();
|
||||
rows.clear();
|
||||
sessions.findById.mockClear();
|
||||
});
|
||||
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', () => {
|
||||
expect(
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
}),
|
||||
).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
|
||||
).resolves.toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' });
|
||||
});
|
||||
|
||||
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => {
|
||||
const user = strategy.validate({
|
||||
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', async () => {
|
||||
const user = await strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -55,36 +74,38 @@ describe('JwtStrategy.validate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', () => {
|
||||
expect(
|
||||
it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
sid: SID,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
).resolves.toStrictEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
sessionId: SID,
|
||||
});
|
||||
// 제한이 없는 토큰은 DB 를 묻지 않는다
|
||||
expect(sessions.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', () => {
|
||||
revokedIds.add('9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a');
|
||||
it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', async () => {
|
||||
revokedIds.add(SID);
|
||||
expect(
|
||||
codeOf(() =>
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a',
|
||||
sid: SID,
|
||||
}),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
// 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다
|
||||
expect(
|
||||
codeOf(() =>
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
@@ -94,7 +115,7 @@ describe('JwtStrategy.validate', () => {
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
codeOf(() =>
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
@@ -104,15 +125,88 @@ describe('JwtStrategy.validate', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => {
|
||||
expect(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
).toThrow(BusinessException);
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', async () => {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
try {
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' });
|
||||
} catch (error) {
|
||||
expect((error as BusinessException).code).toBe('AUTH_001');
|
||||
it('제한이 실린 토큰은 세션 표의 지금 제한을 쓴다 · 유예의 시작 시각은 토큰 그대로 (13단계)', async () => {
|
||||
rows.set(SID, { id: SID, restriction: 'passwordChange', revokedAt: null });
|
||||
await expect(
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: SID,
|
||||
rst: 'passwordChange',
|
||||
tfd: '2026-11-30T15:00:00.000Z',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
sessionId: SID,
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: '2026-11-30T15:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('제한을 푼 뒤의 옛 토큰은 제한 없음으로 — 세션 표가 지웠으면 null (마친 단계로 되돌아가지 않게)', async () => {
|
||||
rows.set(SID, { id: SID, restriction: null, revokedAt: null });
|
||||
const user = await strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: SID,
|
||||
rst: 'passwordChange',
|
||||
});
|
||||
expect(user.restriction).toBeNull();
|
||||
});
|
||||
|
||||
it('제한이 실렸는데 세션 행이 없거나 끝났거나 sid 가 없으면 AUTH_001', async () => {
|
||||
for (const payload of [{ sid: SID }, { sid: undefined }]) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
rst: 'twoFactorSetup',
|
||||
...payload,
|
||||
}),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
}
|
||||
rows.set(SID, {
|
||||
id: SID,
|
||||
restriction: 'twoFactorSetup',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
rst: 'twoFactorSetup',
|
||||
sid: SID,
|
||||
}),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('모르는 제한이 실린 토큰은 AUTH_001 — 제한을 모르고 통과시키지 않는다', async () => {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
rst: 'somethingElse' as never,
|
||||
}),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,14 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { isSessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
@@ -24,12 +26,15 @@ function accessTokenFromCookie(request: Request): string | null {
|
||||
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다
|
||||
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다).
|
||||
* 13단계: 제한(`rst`)이 실린 토큰만 세션 표의 **지금 제한**을 읽어 쓴다 — 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 세션의 제한을 지운 뒤
|
||||
* 옛 토큰으로 온 요청(새로고침 · 다른 탭)이 마친 단계로 되돌아가지 않게. 제한이 없는 토큰은 지금처럼 DB 를 묻지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
constructor(
|
||||
@Inject(AUTH_SETTINGS) settings: AuthSettings,
|
||||
private readonly revoked: RevokedSessions,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
|
||||
@@ -39,7 +44,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: AuthTokenPayload): AuthenticatedUser {
|
||||
async validate(payload: AuthTokenPayload): Promise<AuthenticatedUser> {
|
||||
if (
|
||||
payload.typ !== 'access' ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
@@ -50,6 +55,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
context: { reason: 'not-an-access-token' },
|
||||
});
|
||||
}
|
||||
if (payload.rst !== undefined && !isSessionRestriction(payload.rst)) {
|
||||
// 서명은 맞는데 모르는 제한 — 이 서버가 만든 토큰이 아니거나 판이 다르다. 제한을 모르고 통과시키지 않는다 (닫힌 쪽)
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'unknown-session-restriction' },
|
||||
});
|
||||
}
|
||||
const sessionId = typeof payload.sid === 'string' ? payload.sid : null;
|
||||
if (sessionId !== null && this.revoked.isRevoked(sessionId)) {
|
||||
// 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다
|
||||
@@ -65,6 +76,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
|
||||
: {}),
|
||||
...(sessionId !== null ? { sessionId } : {}),
|
||||
// 제한된 세션 · 2단계 인증 필수의 유예 (13단계) — 있을 때만. 가드가 제한을 본다(세션 표의 지금 값)
|
||||
...(payload.rst !== undefined
|
||||
? { restriction: await this.currentRestriction(sessionId) }
|
||||
: {}),
|
||||
...(typeof payload.tfd === 'string'
|
||||
? { twoFactorDeadline: payload.tfd }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 제한이 실린 토큰 — 세션 표의 지금 제한 (13단계). 행이 없거나 끝난 세션이면 AUTH_001(갱신이 다시 정한다).
|
||||
* 제한은 sid 가 있는 토큰(12단계 뒤)에만 실린다 — sid 가 없으면 받지 않는다
|
||||
*/
|
||||
private async currentRestriction(
|
||||
sessionId: string | null,
|
||||
): Promise<AuthenticatedUser['restriction']> {
|
||||
const row =
|
||||
sessionId === null ? null : await this.sessions.findById(sessionId);
|
||||
if (!row || row.revokedAt !== null) {
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'restricted-session-missing', sid: sessionId },
|
||||
});
|
||||
}
|
||||
return row.restriction;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
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';
|
||||
import {
|
||||
REVOKED_SESSION_SYNC_INTERVAL_MS,
|
||||
RevokedSessions,
|
||||
} from './revoked-sessions';
|
||||
|
||||
const NOW = Date.parse('2026-10-31T03:00:00Z');
|
||||
|
||||
@@ -62,4 +66,80 @@ describe('RevokedSessions (끊은 세션 id 기억 — 계약 5절 "로그인된
|
||||
expect(revoked.size).toBe(1);
|
||||
expect(revoked.isRevoked('fresh', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
describe('15초 DB 동기화 (13단계 — 운영 도구가 끊은 세션)', () => {
|
||||
it('다른 프로세스가 끊은 세션을 다음 동기화에서 읽어 막는다', async () => {
|
||||
await revoked.onApplicationBootstrap();
|
||||
expect(revoked.isRevoked('by-ops', NOW)).toBe(false);
|
||||
|
||||
repository.find.mockResolvedValue([
|
||||
{ id: 'by-ops', revokedAt: new Date(NOW + 5_000) },
|
||||
]);
|
||||
await expect(
|
||||
revoked.syncFromDatabase(NOW + REVOKED_SESSION_SYNC_INTERVAL_MS),
|
||||
).resolves.toBe(1);
|
||||
expect(revoked.isRevoked('by-ops', NOW + 20_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('창은 지난 동기화를 시작한 때보다 60초 앞부터 — 늦게 커밋된 끊기도 잡는다', async () => {
|
||||
await revoked.onApplicationBootstrap(); // 지난 동기화 = NOW
|
||||
repository.find.mockClear();
|
||||
await revoked.syncFromDatabase(NOW + 15_000);
|
||||
expect(repository.find).toHaveBeenCalledWith({
|
||||
select: { id: true, revokedAt: true },
|
||||
where: { revokedAt: MoreThan(new Date(NOW - 60_000)) },
|
||||
order: { revokedAt: 'ASC' },
|
||||
});
|
||||
repository.find.mockClear();
|
||||
await revoked.syncFromDatabase(NOW + 30_000);
|
||||
expect(repository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { revokedAt: MoreThan(new Date(NOW + 15_000 - 60_000)) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('오래 읽지 못했어도 창은 1시간보다 앞서지 않는다', async () => {
|
||||
await revoked.syncFromDatabase(NOW); // 처음 — 1시간 전부터
|
||||
expect(repository.find).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
revokedAt: MoreThan(new Date(NOW - REVOKED_SESSION_MEMORY_MS)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const later = NOW + 3 * REVOKED_SESSION_MEMORY_MS;
|
||||
await revoked.syncFromDatabase(later);
|
||||
expect(repository.find).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
revokedAt: MoreThan(new Date(later - REVOKED_SESSION_MEMORY_MS)),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('DB 를 읽지 못해도 던지지 않고(warn 한 번) 다음 주기에 그사이의 끊기까지 읽는다', async () => {
|
||||
await revoked.onApplicationBootstrap();
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
repository.find.mockRejectedValue(new Error('db down'));
|
||||
await expect(revoked.syncFromDatabase(NOW + 15_000)).resolves.toBe(0);
|
||||
await expect(revoked.syncFromDatabase(NOW + 30_000)).resolves.toBe(0);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
repository.find.mockResolvedValue([
|
||||
{ id: 'while-down', revokedAt: new Date(NOW + 10_000) },
|
||||
]);
|
||||
await revoked.syncFromDatabase(NOW + 45_000);
|
||||
// 지난 성공(NOW) - 60초부터 읽는다 — 실패한 동안 끊긴 것도 든다
|
||||
expect(repository.find).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { revokedAt: MoreThan(new Date(NOW - 60_000)) },
|
||||
}),
|
||||
);
|
||||
expect(revoked.isRevoked('while-down', NOW + 45_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,22 @@ import { AuthSessionEntity } from './auth-session.entity';
|
||||
/** 지난 기억을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* DB 에서 끊은 세션을 다시 채우는 주기 (13단계 — 계약 6절 "도구로 끊은 세션도 15초 안에 막힌다").
|
||||
* 운영 도구(dist/cli/ops.js)는 다른 프로세스라 그 프로세스의 기억은 이 서버에 닿지 않는다 — 세션 끊기는 DB 로 하고, 서버는 이 주기로 DB 를 읽는다
|
||||
*/
|
||||
export const REVOKED_SESSION_SYNC_INTERVAL_MS = 15_000;
|
||||
|
||||
/**
|
||||
* 동기화 창의 겹침 — 지난 동기화를 시작한 때보다 이만큼 앞에서부터 다시 읽는다.
|
||||
* 다른 프로세스가 `revoked_at` 을 적은 뒤 늦게 커밋해도(그사이 동기화가 지나가도) 다음 동기화가 잡는다. 겹쳐 읽은 세션은 기억이 하나로 합친다
|
||||
*/
|
||||
const SYNC_OVERLAP_MS = 60_000;
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 끊은 세션 id 기억 (계약 5절 "로그인된 브라우저" — 끊은 세션은 바로 막는다).
|
||||
*
|
||||
@@ -19,15 +35,21 @@ const SWEEP_INTERVAL_MS = 60_000;
|
||||
* 세션 표의 조건부 UPDATE 가 끝낸 id 를 여기에 넣는다 (auth-session.repository.ts — 한 곳)
|
||||
* - 서버가 다시 뜨면(onApplicationBootstrap) 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다 — 읽지 못하면 기동을 멈춘다
|
||||
* (끊은 세션의 토큰이 만료까지 다시 쓰이는 일이 없게. 운영은 마이그레이션도 기동 때 DB 에 하므로 DB 없이는 어차피 뜨지 않는다)
|
||||
* - 13단계: 15초마다(REVOKED_SESSION_SYNC_INTERVAL_MS) DB 에서 새로 끊긴 세션을 읽어 더한다 — 운영 도구가 끊은 세션(다른 프로세스)도 15초 안에 막힌다.
|
||||
* 읽지 못하면 다음 주기에 다시 — 겹침(SYNC_OVERLAP_MS)이 있어 그사이 끊긴 것도 잡는다. 인스턴스를 늘려도 15초 안에 맞는다
|
||||
* - 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>();
|
||||
/** 마지막으로 DB 에서 채우기를 시작한 때 (epoch ms) — null 이면 아직 채우지 않았다 */
|
||||
private lastSyncedAt: number | null = null;
|
||||
/** 동기화가 도는 중인가 — 한 번에 하나만 */
|
||||
private syncing = false;
|
||||
/** 동기화 실패를 알렸는가 — 이어지는 실패는 회복할 때까지 한 번만 warn */
|
||||
private syncFailureReported = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AuthSessionEntity)
|
||||
@@ -36,19 +58,47 @@ export class RevokedSessions implements OnApplicationBootstrap {
|
||||
|
||||
/** 기동 — 최근 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());
|
||||
const startedAt = Date.now();
|
||||
const loaded = await this.loadRevokedSince(
|
||||
startedAt - REVOKED_SESSION_MEMORY_MS,
|
||||
);
|
||||
this.lastSyncedAt = startedAt;
|
||||
if (loaded > 0) {
|
||||
this.logger.log(`최근 1시간 안에 끊은 세션 ${loaded}개를 불러왔습니다.`);
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
this.logger.log(
|
||||
`최근 1시간 안에 끊은 세션 ${rows.length}개를 불러왔습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 에서 새로 끊긴 세션을 읽어 더한다 (13단계 — 15초마다) → 읽은 수. 실패해도 던지지 않는다(warn 한 번 — 다음 주기에 다시).
|
||||
* 창: 지난 동기화를 시작한 때 - SYNC_OVERLAP_MS 부터(1시간보다 앞이면 1시간 전부터)
|
||||
*/
|
||||
@Interval(REVOKED_SESSION_SYNC_INTERVAL_MS)
|
||||
async syncFromDatabase(now: number = Date.now()): Promise<number> {
|
||||
if (this.syncing) return 0;
|
||||
this.syncing = true;
|
||||
try {
|
||||
const memoryStart = now - REVOKED_SESSION_MEMORY_MS;
|
||||
const since =
|
||||
this.lastSyncedAt === null
|
||||
? memoryStart
|
||||
: Math.max(this.lastSyncedAt - SYNC_OVERLAP_MS, memoryStart);
|
||||
const loaded = await this.loadRevokedSince(since);
|
||||
this.lastSyncedAt = now;
|
||||
if (this.syncFailureReported) {
|
||||
this.syncFailureReported = false;
|
||||
this.logger.log('끊은 세션 동기화가 다시 됩니다.');
|
||||
}
|
||||
return loaded;
|
||||
} catch (error) {
|
||||
if (!this.syncFailureReported) {
|
||||
this.syncFailureReported = true;
|
||||
this.logger.warn(
|
||||
`[SYS_001] 끊은 세션을 DB 에서 읽지 못했습니다 — 다음 주기에 다시 합니다(이 서버가 끊은 세션은 그대로 막힌다): ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +131,19 @@ export class RevokedSessions implements OnApplicationBootstrap {
|
||||
return this.revokedAt.size;
|
||||
}
|
||||
|
||||
/** `since` 뒤에 끊긴 세션을 읽어 기억한다 → 읽은 수 (끊은 차례로 — sweep 이 앞에서부터 본다) */
|
||||
private async loadRevokedSince(since: number): Promise<number> {
|
||||
const rows = await this.sessions.find({
|
||||
select: { id: true, revokedAt: true },
|
||||
where: { revokedAt: MoreThan(new Date(since)) },
|
||||
order: { revokedAt: 'ASC' },
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime());
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private remember(id: string, at: number): void {
|
||||
// 늦게 끊은 시각이 이긴다 — 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다)
|
||||
const previous = this.revokedAt.get(id);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -50,7 +51,7 @@ const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LE
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACT_GROUPS_ROUTE)
|
||||
export class ContactGroupsController {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(CONTACT_RESTORATIONS_ROUTE)
|
||||
export class ContactRestorationsController {
|
||||
constructor(private readonly contacts: ContactsService) {}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -57,7 +58,7 @@ const SIZE_RULE =
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(CONTACTS_ROUTE)
|
||||
export class ContactsController {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -38,7 +39,7 @@ const UNREADABLE =
|
||||
@ApiTags('Contacts')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(DIRECTORY_ENTRIES_ROUTE)
|
||||
export class DirectoryEntriesController {
|
||||
constructor(
|
||||
|
||||
@@ -127,6 +127,27 @@ export class FileSharingRepository {
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 그 직원의 살아 있는 링크(끊지 않았고 · 취소하지 않았고 · 기간 안) id — 운영 도구의 잠금 (13단계) */
|
||||
async aliveIdsOf(owner: string, now: Date): Promise<string[]> {
|
||||
const rows = await this.links
|
||||
.createQueryBuilder('link')
|
||||
.select('link.id', 'id')
|
||||
.where('link.owner_email = :owner', { owner })
|
||||
.andWhere('link.revoked_at IS NULL')
|
||||
.andWhere('link.canceled_at IS NULL')
|
||||
.andWhere('link.expires_at >= :now', { now })
|
||||
.getRawMany<{ id: string }>();
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/** 링크 하나와 파일 (주인과 상관없이) — 운영 도구의 링크 끊기 (13단계) */
|
||||
async findById(id: string): Promise<ShareLinkRecord | null> {
|
||||
const link = await this.links.findOneBy({ id });
|
||||
if (!link) return null;
|
||||
const [record] = await this.withFiles([link]);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다).
|
||||
* @returns 더했으면 true, 상한에 닿았으면 false
|
||||
|
||||
@@ -347,6 +347,57 @@ export class FileSharingService implements BeforeApplicationShutdown {
|
||||
return this.summarize(record, await this.fileStatuses([record]));
|
||||
}
|
||||
|
||||
// ── 운영 도구 (13단계 — 계약 6절 "운영 도구") ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 신고된 공개 링크 주소로 링크를 찾는다 (운영 도구의 「링크 끊기」) — 우리 공개 화면 주소가 아니거나 없는 링크 · 취소한 링크면 null.
|
||||
* 주소 뒤의 쿼리 · 조각은 무시한다(받는 쪽 장비가 붙였을 수 있다)
|
||||
*/
|
||||
async findForOperator(url: string): Promise<ShareLinkRecord | null> {
|
||||
const token = tokenFromShareUrl(this.publicOrigin, url, {
|
||||
allowQuery: true,
|
||||
});
|
||||
if (token === null) return null;
|
||||
const [record] = await this.repository.findByTokenHashes([
|
||||
hashShareToken(token),
|
||||
]);
|
||||
return record && record.link.canceledAt === null ? record : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 링크를 끊는다 (`revoked_by = operator`) — 이번에 끊었으면 true (이미 끊었거나 기간이 지났으면 false).
|
||||
* 파일의 보관 기간을 다시 맞춘다 (보낸 사람이 끊을 때와 같다)
|
||||
*/
|
||||
async revokeAsOperator(linkId: string): Promise<boolean> {
|
||||
const record = await this.repository.findById(linkId);
|
||||
if (!record || record.link.canceledAt !== null) return false;
|
||||
const revoked = await this.repository.revoke(
|
||||
linkId,
|
||||
'operator',
|
||||
new Date(),
|
||||
);
|
||||
if (revoked) {
|
||||
await this.refreshRetention(record.files.map((file) => file.fileId));
|
||||
this.logger.log(
|
||||
`운영자가 대용량 첨부 링크를 끊었습니다: user=${maskEmail(record.link.ownerEmail)}, link=${linkId}, 파일 ${record.files.length}개`,
|
||||
);
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/** 그 직원의 살아 있는 링크를 모두 운영자 끊기로 → 끊은 수 (운영 도구의 잠금 — 묻고 한다) */
|
||||
async revokeAllOfOwnerAsOperator(owner: string): Promise<number> {
|
||||
const ids = await this.repository.aliveIdsOf(
|
||||
owner.toLowerCase(),
|
||||
new Date(),
|
||||
);
|
||||
let revoked = 0;
|
||||
for (const id of ids) {
|
||||
if (await this.revokeAsOperator(id)) revoked += 1;
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
// ── 인용 속 링크의 상태 (POST /api/share-link-lookups) ─────────────────────────
|
||||
|
||||
/** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
@@ -31,7 +32,7 @@ export const SHARE_LINK_LOOKUPS_ROUTE = 'share-link-lookups';
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(SHARE_LINK_LOOKUPS_ROUTE)
|
||||
export class ShareLinkLookupController {
|
||||
constructor(private readonly sharing: FileSharingService) {}
|
||||
|
||||
@@ -90,6 +90,16 @@ class FakeRepository {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/** 실제 저장소와 같은 조건 — ready 이고 아직 위험으로 적히지 않았을 때만 */
|
||||
markInfected(id: string, changes: Partial<StoredFileEntity>) {
|
||||
const row = this.rows.get(id);
|
||||
if (!row || row.state !== 'ready' || row.scanStatus === 'infected') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
Object.assign(row, changes, { scanStatus: 'infected' });
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
update(id: string, changes: Partial<StoredFileEntity>) {
|
||||
const row = this.rows.get(id);
|
||||
if (row) Object.assign(row, changes);
|
||||
@@ -305,6 +315,8 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => {
|
||||
fileId: 'bad',
|
||||
name: 'bad.zip',
|
||||
scanStatus: 'infected',
|
||||
size: repository.rows.get('bad')?.size,
|
||||
uploadedAt: expect.any(Date) as Date,
|
||||
},
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
@@ -312,6 +324,23 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('같은 파일의 검사가 겹쳐 다른 쪽이 먼저 위험으로 적었으면 알리지 않는다 — 알림 메일은 파일마다 한 번 (리뷰 L1)', async () => {
|
||||
repository.rows.set('bad', file('bad'));
|
||||
verdicts.set('bad', { kind: 'infected', signature: 'X' });
|
||||
// 이 검사가 끝나기 전에 다른 검사(다른 인스턴스)가 먼저 위험으로 적었다
|
||||
blobs.openRead.mockImplementation((id: string) => {
|
||||
const row = repository.rows.get(id);
|
||||
if (row) row.scanStatus = 'infected';
|
||||
return Promise.resolve(Readable.from([Buffer.from(id)]));
|
||||
});
|
||||
const events: FileScanVerdictEvent[] = [];
|
||||
service.onVerdict((event) => events.push(event));
|
||||
|
||||
await run();
|
||||
expect(repository.rows.get('bad')?.scanStatus).toBe('infected');
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('검사기에 닿지 못하면 검사 중 그대로 두고 1분부터 늘려 다시 — 이어서 실패하면 error (닫힌 쪽)', async () => {
|
||||
repository.rows.set('x', file('x'));
|
||||
verdicts.set('x', new ClamavUnavailableError('connect ECONNREFUSED'));
|
||||
|
||||
@@ -226,8 +226,8 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown {
|
||||
scanStatus: 'infected' as const,
|
||||
scannedAt: now,
|
||||
};
|
||||
const changed = await this.repository.transition(file.id, ['ready'], {
|
||||
scanStatus: 'infected',
|
||||
// 처음 위험으로 적을 때만 changed — 검사가 겹쳐도 알림은 파일마다 한 번 (scan_status IS DISTINCT FROM 'infected')
|
||||
const changed = await this.repository.markInfected(file.id, {
|
||||
scanSignature: verdict.signature,
|
||||
scannedAt: now,
|
||||
scanNextAt: null,
|
||||
@@ -247,11 +247,14 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown {
|
||||
);
|
||||
}
|
||||
if (!changed) return;
|
||||
// 위험 판정을 처음 적었을 때만 여기까지 온다(changed) — 받은편지함 알림 메일도 파일마다 한 번이다 (13단계)
|
||||
const event: FileScanVerdictEvent = {
|
||||
ownerEmail: file.ownerEmail,
|
||||
fileId: file.id,
|
||||
name: file.name,
|
||||
scanStatus: 'infected',
|
||||
size: file.size,
|
||||
uploadedAt: file.completedAt ?? file.createdAt,
|
||||
};
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
|
||||
@@ -182,6 +182,26 @@ export class FileStorageRepository {
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 위험 판정을 적는다 — 아직 위험으로 적히지 않았고 올리기를 끝낸(ready) 파일일 때만 (조건부 UPDATE 한 번).
|
||||
* 같은 파일의 검사가 겹쳐도(인스턴스 둘 · 다시 검사) 처음 적은 쪽만 true — 받은편지함 알림 메일은 파일마다 한 번이다 (13단계)
|
||||
* @returns 적었으면 true
|
||||
*/
|
||||
async markInfected(
|
||||
id: string,
|
||||
changes: Partial<StoredFileEntity>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.files
|
||||
.createQueryBuilder()
|
||||
.update(StoredFileEntity)
|
||||
.set({ ...changes, scanStatus: 'infected' })
|
||||
.where('id = :id', { id })
|
||||
.andWhere(`state = 'ready'`)
|
||||
.andWhere(`scan_status IS DISTINCT FROM 'infected'`)
|
||||
.execute();
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기간이 지난 파일을 지움(기간 지남)으로 — 그 순간에도 기간이 지났을 때만(그사이 임시 저장 · 링크로 늘었으면 그대로 둔다).
|
||||
* @returns 바꿨으면 true
|
||||
|
||||
@@ -110,6 +110,10 @@ export interface FileScanVerdictEvent {
|
||||
readonly fileId: string;
|
||||
readonly name: string;
|
||||
readonly scanStatus: ScanStatus;
|
||||
/** 파일 크기 (바이트) — 받은편지함 알림 메일의 「크기」 (13단계) */
|
||||
readonly size: number;
|
||||
/** 올린 때(다 올린 때 — 없으면 만들기 시작한 때) — 받은편지함 알림 메일의 「올린 때」 (13단계) */
|
||||
readonly uploadedAt: Date;
|
||||
}
|
||||
|
||||
/** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -69,7 +70,7 @@ function header(req: Request, name: string): string | undefined {
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(FILE_UPLOADS_ROUTE)
|
||||
export class FileUploadController {
|
||||
constructor(private readonly files: FileStorageService) {}
|
||||
@@ -252,7 +253,7 @@ export class FileUploadController {
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(FILE_QUOTA_ROUTE)
|
||||
export class FileQuotaController {
|
||||
constructor(private readonly files: FileStorageService) {}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -60,7 +61,7 @@ const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-signature')
|
||||
export class MailSignatureController {
|
||||
constructor(private readonly signatures: MailSignatureService) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings';
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(MAIL_USER_SETTINGS_ROUTE)
|
||||
export class MailUserSettingsController {
|
||||
constructor(private readonly settings: MailUserSettingsService) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -36,8 +37,7 @@ const SAVE_BAD_REQUEST =
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller(DRAFTS_ROUTE)
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
@@ -68,7 +71,7 @@ export function releaseWhenDone(res: Response, release: () => void): void {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(MAIL_IMAGES_ROUTE)
|
||||
export class MailImageController {
|
||||
constructor(private readonly proxy: MailImageProxyService) {}
|
||||
|
||||
@@ -7949,6 +7949,19 @@ describe('JmapMailProvider', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('첫 메서드(제출 찾기)가 막히면 그 오류로 — 뒤 메서드의 invalidResultReference(엔진 장애 503)로 보이지 않는다 (실사용 시험)', async () => {
|
||||
const { provider } = fakeClient([
|
||||
[
|
||||
['error', { type: 'forbidden' }, 'pq'],
|
||||
['error', { type: 'invalidResultReference' }, 'pg'],
|
||||
['error', { type: 'invalidResultReference' }, 'g'],
|
||||
],
|
||||
]);
|
||||
await expect(provider.listScheduledMessages(ARA)).rejects.toMatchObject(
|
||||
{ code: 'AUTH_003' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('붙잡은 발송 취소 (cancelPendingSend)', () => {
|
||||
|
||||
@@ -852,6 +852,11 @@ export class JmapMailProvider implements MailProvider {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Identity/get', { accountId, ids: null }, 'i'],
|
||||
]);
|
||||
const identities = findResult(responses, 'i', 'Identity/get');
|
||||
if (!identities.ok && identities.error.type === 'forbidden') {
|
||||
// 세션은 열렸는데 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면 그렇게 알린다(13단계, 아니면 아래가 지금처럼 던진다)
|
||||
await this.jmap.throwIfPasswordExpiredFor(session.username);
|
||||
}
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapIdentity>>(
|
||||
responses,
|
||||
'i',
|
||||
@@ -2589,6 +2594,9 @@ export class JmapMailProvider implements MailProvider {
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
// 첫 메서드(제출 찾기)의 오류부터 본다 — 막혔으면(forbidden) 뒤 메서드는 그 결과를 참조하지 못해 invalidResultReference 로 끝난다.
|
||||
// 뒤의 것을 알리면 권한 문제가 엔진 장애(503 · ERROR)로 보였다 (실사용 시험 — 재설정 직후 15초)
|
||||
expectResult(responses, `${HELD_CALLS}q`, 'EmailSubmission/query');
|
||||
const { list: submissions = [] } = expectResult<
|
||||
JmapGetResult<JmapEmailSubmission>
|
||||
>(responses, `${HELD_CALLS}g`, 'EmailSubmission/get');
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
MailFilterSet,
|
||||
MailVacationState,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
LOCK_FORWARD_RULE_NAME,
|
||||
OpsEngineError,
|
||||
} from '@/modules/ops/ops-engine.port';
|
||||
import type { JmapAccountSecurityProvider } from './jmap-account-security.provider';
|
||||
import type { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import { JmapOpsEngine, permissionsWithLogin } from './jmap-ops-engine';
|
||||
|
||||
const DOMAIN = 'arasystem.test';
|
||||
const ADMIN = `admin@${DOMAIN}`;
|
||||
const ARA = `ara.kim@${DOMAIN}`;
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: DOMAIN,
|
||||
gatewayUser: `mail-gateway@${DOMAIN}`,
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 1,
|
||||
attachmentTotalMaxBytes: 1,
|
||||
blockedAccounts: new Set([ADMIN, `mail-directory@${DOMAIN}`]),
|
||||
directoryAccount: `mail-directory@${DOMAIN}`,
|
||||
};
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const adminSession = {
|
||||
username: ADMIN,
|
||||
apiUrl: 'https://mail.example/jmap/',
|
||||
primaryAccounts: { 'urn:stalwart:jmap': 'adm' },
|
||||
};
|
||||
|
||||
/** 계정 하나 (관리 API 의 모양) */
|
||||
const araAccount = {
|
||||
id: 'acc1',
|
||||
name: 'ara.kim',
|
||||
emailAddress: ARA,
|
||||
description: '김아라',
|
||||
permissions: { '@type': 'Inherit' },
|
||||
credentials: {
|
||||
'0': {
|
||||
'@type': 'Password',
|
||||
secret: '****',
|
||||
otpAuth: '****',
|
||||
expiresAt: null,
|
||||
},
|
||||
'1': { '@type': 'AppPassword', secret: '****' },
|
||||
},
|
||||
quotas: { maxDiskQuota: 1073741824 },
|
||||
usedDiskQuota: 2048,
|
||||
};
|
||||
|
||||
describe('permissionsWithLogin — 로그인 권한 빼기 · 되돌리기 (13단계)', () => {
|
||||
it('상속(Inherit)이면 Merge 로 authenticate 만 뺀다 · 되돌리면 다시 상속', () => {
|
||||
const locked = permissionsWithLogin({ '@type': 'Inherit' }, false);
|
||||
expect(locked).toEqual({
|
||||
'@type': 'Merge',
|
||||
enabledPermissions: {},
|
||||
disabledPermissions: { authenticate: true },
|
||||
});
|
||||
expect(permissionsWithLogin(locked as never, true)).toEqual({
|
||||
'@type': 'Inherit',
|
||||
});
|
||||
});
|
||||
|
||||
it('Merge 에 다른 권한이 있으면 그것은 지킨다', () => {
|
||||
const current = {
|
||||
'@type': 'Merge',
|
||||
enabledPermissions: { emailSend: true },
|
||||
disabledPermissions: { jmapPrincipalQuery: true, authenticate: true },
|
||||
};
|
||||
expect(permissionsWithLogin(current, true)).toEqual({
|
||||
'@type': 'Merge',
|
||||
enabledPermissions: { emailSend: true },
|
||||
disabledPermissions: { jmapPrincipalQuery: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('이미 그 상태면 unchanged · 직접 정한 권한(Replace)은 바꾸지 않는다', () => {
|
||||
expect(permissionsWithLogin({ '@type': 'Inherit' }, true)).toBe(
|
||||
'unchanged',
|
||||
);
|
||||
expect(() =>
|
||||
permissionsWithLogin({ '@type': 'Replace', permissions: {} }, false),
|
||||
).toThrow(OpsEngineError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => {
|
||||
let fetchMock: jest.SpyInstance;
|
||||
let accountSecurity: { deleteAllAppPasswords: jest.Mock };
|
||||
let filterScripts: {
|
||||
getFilters: jest.Mock;
|
||||
setFilters: jest.Mock;
|
||||
getVacation: jest.Mock;
|
||||
setVacation: jest.Mock;
|
||||
};
|
||||
let engine: JmapOpsEngine;
|
||||
|
||||
/** n 번째 관리 API 호출의 본문 */
|
||||
const bodyAt = (index: number) =>
|
||||
JSON.parse(
|
||||
(fetchMock.mock.calls[index] as [string, RequestInit])[1].body as string,
|
||||
) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
|
||||
const signedIn = async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(adminSession))
|
||||
.mockResolvedValueOnce(
|
||||
json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }),
|
||||
);
|
||||
await expect(engine.signIn('admin-secret')).resolves.toBe('ok');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = jest.spyOn(global, 'fetch');
|
||||
accountSecurity = { deleteAllAppPasswords: jest.fn().mockResolvedValue(3) };
|
||||
const filters: MailFilterSet = { rules: [], version: 'v1', isActive: true };
|
||||
const vacation: MailVacationState = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
isActive: true,
|
||||
};
|
||||
filterScripts = {
|
||||
getFilters: jest.fn().mockResolvedValue(filters),
|
||||
setFilters: jest.fn().mockResolvedValue(filters),
|
||||
getVacation: jest.fn().mockResolvedValue(vacation),
|
||||
setVacation: jest.fn().mockResolvedValue(vacation),
|
||||
};
|
||||
engine = new JmapOpsEngine(
|
||||
settings,
|
||||
ADMIN,
|
||||
accountSecurity as unknown as JmapAccountSecurityProvider,
|
||||
filterScripts as unknown as JmapFilterScriptStore,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => fetchMock.mockRestore());
|
||||
|
||||
it('관리자 로그인 — 401 은 rejected, 402 는 two-factor, 관리 권한이 없으면 not-admin', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(engine.signIn('x')).resolves.toBe('rejected');
|
||||
fetchMock.mockResolvedValueOnce(json({}, 402));
|
||||
await expect(engine.signIn('x')).resolves.toBe('two-factor');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(adminSession))
|
||||
.mockResolvedValueOnce(
|
||||
json({ methodResponses: [['error', { type: 'forbidden' }, 'p']] }),
|
||||
);
|
||||
await expect(engine.signIn('x')).resolves.toBe('not-admin');
|
||||
// 관리자 Basic 은 관리자 주소로 · 엔진 안 주소(MAIL_JMAP_URL)에 경로만 붙인다
|
||||
const [url, init] = fetchMock.mock.calls[2] as [string, RequestInit];
|
||||
expect(url).toBe('http://mail:8080/jmap/session');
|
||||
const authorization = (init.headers as Record<string, string>)
|
||||
.authorization;
|
||||
expect(Buffer.from(authorization.slice(6), 'base64').toString()).toBe(
|
||||
`${ADMIN}:x`,
|
||||
);
|
||||
expect((fetchMock.mock.calls[3] as [string])[0]).toBe(
|
||||
'http://mail:8080/jmap/',
|
||||
);
|
||||
});
|
||||
|
||||
it('로그인하지 않았으면 관리 API 를 부르지 않는다', async () => {
|
||||
await expect(engine.findEmployee(ARA)).rejects.toMatchObject({
|
||||
kind: 'not-signed-in',
|
||||
});
|
||||
});
|
||||
|
||||
it('직원 찾기 — 잠금 · 2단계 · 앱 비밀번호 수 · 용량을 읽는다, 관리자 주소는 직원이 아니다', async () => {
|
||||
await signedIn();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/query', { ids: ['acc1'] }, 'q']],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']],
|
||||
}),
|
||||
);
|
||||
await expect(engine.findEmployee(ARA)).resolves.toEqual({
|
||||
id: 'acc1',
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
locked: false,
|
||||
customPermissions: false,
|
||||
passwordExpired: false,
|
||||
twoFactorEnabled: true,
|
||||
appPasswords: 1,
|
||||
usedBytes: 2048,
|
||||
quotaBytes: 1073741824,
|
||||
});
|
||||
expect(bodyAt(2).methodCalls[0][1]).toMatchObject({
|
||||
filter: { name: 'ara.kim' },
|
||||
});
|
||||
await expect(engine.findEmployee(ADMIN)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('입사 — 직원 역할 · 임시 비밀번호 · 지난 만료 · 용량을 한 번에 만든다', async () => {
|
||||
await signedIn();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['x:Domain/query', { ids: ['d1'] }, 'q'],
|
||||
['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'],
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['x:Account/set', { created: { a: { id: 'acc9' } } }, 's'],
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
[
|
||||
'x:Account/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
...araAccount,
|
||||
id: 'acc9',
|
||||
emailAddress: `hong@${DOMAIN}`,
|
||||
credentials: {
|
||||
'0': {
|
||||
'@type': 'Password',
|
||||
expiresAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
const created = await engine.createEmployee({
|
||||
localPart: 'hong',
|
||||
name: '홍길동',
|
||||
quotaBytes: 5,
|
||||
tempPassword: 'Temp1234Temp1234',
|
||||
});
|
||||
expect(created).toMatchObject({ id: 'acc9', passwordExpired: true });
|
||||
const create = bodyAt(3).methodCalls[0][1].create as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(create.a).toMatchObject({
|
||||
'@type': 'User',
|
||||
name: 'hong',
|
||||
domainId: 'd1',
|
||||
description: '홍길동',
|
||||
roles: { '@type': 'User' },
|
||||
quotas: { maxDiskQuota: 5 },
|
||||
});
|
||||
const credential = (
|
||||
create.a.credentials as Record<string, Record<string, unknown>>
|
||||
)['0'];
|
||||
expect(credential.secret).toBe('Temp1234Temp1234');
|
||||
expect(Date.parse(credential.expiresAt as string)).toBeLessThan(Date.now());
|
||||
});
|
||||
|
||||
it('약한 임시 비밀번호(invalidProperties secret)는 weak-password, 같은 이름은 exists', async () => {
|
||||
await signedIn();
|
||||
const domain = json({
|
||||
methodResponses: [
|
||||
['x:Domain/query', { ids: ['d1'] }, 'q'],
|
||||
['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'],
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce(domain).mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
[
|
||||
'x:Account/set',
|
||||
{
|
||||
notCreated: {
|
||||
a: { type: 'invalidProperties', properties: ['secret'] },
|
||||
},
|
||||
},
|
||||
's',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
engine.createEmployee({
|
||||
localPart: 'hong',
|
||||
name: '홍',
|
||||
quotaBytes: null,
|
||||
tempPassword: 'weak',
|
||||
}),
|
||||
).rejects.toMatchObject({ kind: 'weak-password' });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
[
|
||||
'x:Account/set',
|
||||
{ notCreated: { a: { type: 'primaryKeyViolation' } } },
|
||||
's',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
engine.createEmployee({
|
||||
localPart: 'hong',
|
||||
name: '홍',
|
||||
quotaBytes: null,
|
||||
tempPassword: 'x',
|
||||
}),
|
||||
).rejects.toMatchObject({ kind: 'exists' });
|
||||
});
|
||||
|
||||
it('재설정 · 2단계 해제 · 잠금은 비밀번호 자격의 칸 · 권한 칸을 패치한다', async () => {
|
||||
await signedIn();
|
||||
const account = json({
|
||||
methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']],
|
||||
});
|
||||
const updated = json({
|
||||
methodResponses: [['x:Account/set', { updated: { acc1: null } }, 's']],
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(account)
|
||||
.mockResolvedValueOnce(updated)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['x:Account/set', { updated: { acc1: null } }, 's'],
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['x:Account/set', { updated: { acc1: null } }, 's'],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await engine.resetPassword('acc1', 'NewTemp1234NewTe');
|
||||
const reset = (
|
||||
bodyAt(3).methodCalls[0][1].update as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).acc1;
|
||||
expect(reset['credentials/0/secret']).toBe('NewTemp1234NewTe');
|
||||
expect(Date.parse(reset['credentials/0/expiresAt'] as string)).toBeLessThan(
|
||||
Date.now(),
|
||||
);
|
||||
// 2단계 인증(OTP)은 칸에 넣지 않는다 — 관리자의 비밀번호 바꾸기는 OTP 를 남긴다(엔진 확인)
|
||||
expect(reset).not.toHaveProperty('credentials/0/otpAuth');
|
||||
|
||||
await engine.removeTwoFactor('acc1');
|
||||
expect(
|
||||
(bodyAt(5).methodCalls[0][1].update as Record<string, unknown>).acc1,
|
||||
).toEqual({ 'credentials/0/otpAuth': null });
|
||||
|
||||
await engine.setLoginEnabled('acc1', false);
|
||||
expect(
|
||||
(bodyAt(7).methodCalls[0][1].update as Record<string, unknown>).acc1,
|
||||
).toEqual({
|
||||
permissions: {
|
||||
'@type': 'Merge',
|
||||
enabledPermissions: {},
|
||||
disabledPermissions: { authenticate: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('주소만으로 직원인지 — 관리자 · 게이트웨이 · 사내 목록 · 다른 도메인은 아니다 (엔진에 묻지 않는다)', () => {
|
||||
expect(engine.isEmployeeAddress(ARA)).toBe(true);
|
||||
expect(engine.isEmployeeAddress(` ${ARA.toUpperCase()} `)).toBe(true);
|
||||
for (const address of [
|
||||
ADMIN,
|
||||
`mail-gateway@${DOMAIN}`,
|
||||
`mail-directory@${DOMAIN}`,
|
||||
'ara.kim@other.test',
|
||||
]) {
|
||||
expect(engine.isEmployeeAddress(address)).toBe(false);
|
||||
}
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('계정 종류(@type)를 읽고 User 만 직원이다 — Group(공유 메일함)은 현황 · 명령에서 뺀다', async () => {
|
||||
await signedIn();
|
||||
const teamAccount = {
|
||||
...araAccount,
|
||||
id: 'grp1',
|
||||
'@type': 'Group',
|
||||
name: 'team',
|
||||
emailAddress: `team@${DOMAIN}`,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['x:Account/query', { ids: ['acc1', 'grp1'] }, 'q'],
|
||||
[
|
||||
'x:Account/get',
|
||||
{ list: [{ ...araAccount, '@type': 'User' }, teamAccount] },
|
||||
'g',
|
||||
],
|
||||
],
|
||||
}),
|
||||
);
|
||||
const employees = await engine.listEmployees();
|
||||
expect(employees.map((employee) => employee.email)).toEqual([ARA]);
|
||||
// 칸을 적어야 돌려준다 — @type 을 함께 읽는다
|
||||
expect(bodyAt(2).methodCalls[1][1].properties).toContain('@type');
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/query', { ids: ['grp1'] }, 'q']],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/get', { list: [teamAccount] }, 'g']],
|
||||
}),
|
||||
);
|
||||
await expect(engine.findEmployee(`team@${DOMAIN}`)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('메서드의 forbidden 은 그 일의 거절(refused)이다 — 관리자 로그인이 끊긴 것은 HTTP 401 · 403 뿐', async () => {
|
||||
await signedIn();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['error', { type: 'forbidden' }, 's']],
|
||||
}),
|
||||
);
|
||||
await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({
|
||||
kind: 'refused',
|
||||
detail: 'forbidden',
|
||||
});
|
||||
// 로그인은 그대로다 — 다음 호출도 보낸다
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:Account/set', { destroyed: ['acc1'] }, 's']],
|
||||
}),
|
||||
);
|
||||
await expect(engine.deleteAccount('acc1')).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(json({}, 403));
|
||||
await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({
|
||||
kind: 'not-signed-in',
|
||||
});
|
||||
});
|
||||
|
||||
it('머리글 뒤 본문이 멈추면 제한 시간에 끊는다(timeout) — 본문 읽기도 같은 시간 안', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
await signedIn();
|
||||
// 머리글은 곧 오고 본문은 오지 않는 응답 — 신호가 끊기면 읽기도 끊긴다
|
||||
fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => {
|
||||
const signal = init.signal as AbortSignal;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
signal.addEventListener('abort', () =>
|
||||
controller.error(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
},
|
||||
});
|
||||
return Promise.resolve(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
const pending = engine.deleteAccount('acc1');
|
||||
const settled = expect(pending).rejects.toMatchObject({
|
||||
kind: 'unavailable',
|
||||
detail: 'timeout',
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(15_000);
|
||||
await settled;
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('본문이 JSON 이 아니면 malformed-response', async () => {
|
||||
await signedIn();
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response('<html>oops</html>', { status: 200 }),
|
||||
);
|
||||
await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({
|
||||
kind: 'unavailable',
|
||||
detail: 'malformed-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('관리자 로그인이 그사이 끊기면(401) not-signed-in — 다시 로그인할 때까지 부르지 않는다', async () => {
|
||||
await signedIn();
|
||||
fetchMock.mockResolvedValueOnce(json({}, 401));
|
||||
await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({
|
||||
kind: 'not-signed-in',
|
||||
});
|
||||
await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({
|
||||
kind: 'not-signed-in',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('잠금 Sieve — 넘기기 규칙을 맨 앞에(모든 메일 · 사본) · 자동 응답을 켠다, 떼기는 그 규칙만 빼고 자동 응답을 끈다', async () => {
|
||||
const userRule = {
|
||||
id: 'r1',
|
||||
name: '내 규칙',
|
||||
isEnabled: true,
|
||||
match: 'any' as const,
|
||||
conditions: [
|
||||
{ field: 'from' as const, operator: 'contains' as const, value: 'x' },
|
||||
],
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
forwardTo: null,
|
||||
},
|
||||
};
|
||||
filterScripts.getFilters.mockResolvedValue({
|
||||
rules: [userRule],
|
||||
version: 'v1',
|
||||
isActive: true,
|
||||
});
|
||||
await engine.applyLockRules(ARA, {
|
||||
forwardTo: `doyun.lee@${DOMAIN}`,
|
||||
autoReply: { subject: '부재 안내', body: '퇴사했습니다.' },
|
||||
});
|
||||
const saved = (filterScripts.setFilters.mock.calls[0] as unknown[])[1] as {
|
||||
rules: {
|
||||
name: string;
|
||||
match: string;
|
||||
actions: { forwardTo: string | null };
|
||||
}[];
|
||||
version: string;
|
||||
};
|
||||
expect(saved.version).toBe('v1');
|
||||
expect(saved.rules.map((rule) => rule.name)).toEqual([
|
||||
LOCK_FORWARD_RULE_NAME,
|
||||
'내 규칙',
|
||||
]);
|
||||
expect(saved.rules[0]).toMatchObject({
|
||||
match: 'always',
|
||||
actions: { forwardTo: `doyun.lee@${DOMAIN}` },
|
||||
});
|
||||
expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재 안내',
|
||||
textBody: '퇴사했습니다.',
|
||||
});
|
||||
|
||||
filterScripts.getFilters.mockResolvedValue({
|
||||
rules: saved.rules,
|
||||
version: 'v2',
|
||||
isActive: true,
|
||||
});
|
||||
filterScripts.getVacation.mockResolvedValue({
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '부재 안내',
|
||||
textBody: '퇴사했습니다.',
|
||||
isActive: true,
|
||||
});
|
||||
await expect(engine.getLockRules(ARA)).resolves.toEqual({
|
||||
forwardTo: `doyun.lee@${DOMAIN}`,
|
||||
autoReply: true,
|
||||
});
|
||||
await engine.clearLockRules(ARA);
|
||||
expect(filterScripts.setFilters).toHaveBeenLastCalledWith(ARA, {
|
||||
rules: [saved.rules[1]],
|
||||
version: 'v2',
|
||||
});
|
||||
expect(filterScripts.setVacation).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
expect.objectContaining({ isEnabled: false, subject: '부재 안내' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => {
|
||||
filterScripts.getFilters.mockResolvedValue({
|
||||
rules: Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `r${index}`,
|
||||
name: `규칙 ${index}`,
|
||||
isEnabled: true,
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: true,
|
||||
flag: false,
|
||||
forwardTo: null,
|
||||
},
|
||||
})),
|
||||
version: 'v1',
|
||||
isActive: true,
|
||||
});
|
||||
await expect(
|
||||
engine.applyLockRules(ARA, {
|
||||
forwardTo: `doyun.lee@${DOMAIN}`,
|
||||
autoReply: null,
|
||||
}),
|
||||
).rejects.toMatchObject({ kind: 'too-many-rules' });
|
||||
expect(filterScripts.setFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('앱 비밀번호 지우기는 대리 접근 어댑터에 맡긴다', async () => {
|
||||
await expect(engine.deleteAppPasswords(ARA)).resolves.toBe(3);
|
||||
expect(accountSecurity.deleteAllAppPasswords).toHaveBeenCalledWith(ARA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isEmployeeAddress } from '@/modules/auth/employee-address';
|
||||
import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
MAIL_SETTINGS,
|
||||
isMailEngineConfigured,
|
||||
type MailSettings,
|
||||
} from '@/modules/mail/mail.settings';
|
||||
import type { MailFilterRule } from '@/modules/mail/mail.types';
|
||||
import {
|
||||
LOCK_FORWARD_RULE_NAME,
|
||||
OPS_ADMIN_ACCOUNT,
|
||||
OpsEngineError,
|
||||
type EngineEmployee,
|
||||
type EngineMailingList,
|
||||
type LockRules,
|
||||
type LockRulesState,
|
||||
type OpsEngine,
|
||||
type OpsSignInResult,
|
||||
} from '@/modules/ops/ops-engine.port';
|
||||
import { JmapAccountSecurityProvider } from './jmap-account-security.provider';
|
||||
import { JmapFilterScriptStore } from './jmap-filter-script';
|
||||
import { basicAuthorization, toRelativeReference } from './jmap.client';
|
||||
import { fallbackDisplayName } from './jmap.mapper';
|
||||
import type {
|
||||
JmapError,
|
||||
JmapInvocation,
|
||||
JmapResponseBody,
|
||||
JmapSessionResource,
|
||||
} from './jmap.types';
|
||||
|
||||
/** 관리 API(Stalwart `x:` 객체)의 기능 — 관리자 세션의 primaryAccounts 에서 이 이름으로 계정 id 를 찾는다 */
|
||||
const MANAGEMENT_CAPABILITY = 'urn:stalwart:jmap';
|
||||
const CORE_CAPABILITY = 'urn:ietf:params:jmap:core';
|
||||
const SESSION_PATH = '/jmap/session';
|
||||
/** 관리 API 한 번의 제한 시간 */
|
||||
const ADMIN_REQUEST_TIMEOUT_MS = 15_000;
|
||||
/** 목록을 한 번에 읽는 상한 — 직원 · 공용 주소 수보다 넉넉하게 */
|
||||
const LIST_LIMIT = 1_000;
|
||||
/** 계정에서 읽는 칸 — 현황 · 명령에 쓰는 것만 */
|
||||
const ACCOUNT_PROPERTIES = [
|
||||
// 계정 종류 — 칸을 적어야 돌려준다(2026-11-02 확인). User 만 직원이다(Group 은 공유 메일함)
|
||||
'@type',
|
||||
'name',
|
||||
'emailAddress',
|
||||
'description',
|
||||
'permissions',
|
||||
'credentials',
|
||||
'quotas',
|
||||
'usedDiskQuota',
|
||||
];
|
||||
/** 만료를 지난 날로 — "다음 로그인 때 바꾸기" (엔진이 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다 — 2026-11-02 확인) */
|
||||
const EXPIRED_AT_OFFSET_MS = 60_000;
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
const HTTP_PAYMENT_REQUIRED = 402;
|
||||
const HTTP_FORBIDDEN = 403;
|
||||
|
||||
/** 엔진의 계정 (관리 API — 읽는 칸만) */
|
||||
interface JmapAccount {
|
||||
readonly id?: string;
|
||||
readonly '@type'?: unknown;
|
||||
readonly name?: unknown;
|
||||
readonly emailAddress?: unknown;
|
||||
readonly description?: unknown;
|
||||
readonly permissions?: JmapPermissions;
|
||||
readonly credentials?: Record<string, JmapCredential | undefined>;
|
||||
readonly quotas?: { readonly maxDiskQuota?: unknown } | null;
|
||||
readonly usedDiskQuota?: unknown;
|
||||
}
|
||||
|
||||
interface JmapPermissions {
|
||||
readonly '@type'?: unknown;
|
||||
readonly enabledPermissions?: Record<string, boolean | undefined>;
|
||||
readonly disabledPermissions?: Record<string, boolean | undefined>;
|
||||
readonly permissions?: Record<string, boolean | undefined>;
|
||||
}
|
||||
|
||||
interface JmapCredential {
|
||||
readonly '@type'?: unknown;
|
||||
readonly expiresAt?: unknown;
|
||||
/** 켜져 있으면 "****"(가린 값), 꺼져 있으면 칸이 없다 */
|
||||
readonly otpAuth?: unknown;
|
||||
}
|
||||
|
||||
interface JmapMailingList {
|
||||
readonly id?: string;
|
||||
readonly name?: unknown;
|
||||
readonly emailAddress?: unknown;
|
||||
readonly description?: unknown;
|
||||
readonly recipients?: Record<string, boolean | undefined> | null;
|
||||
}
|
||||
|
||||
interface AdminSession {
|
||||
readonly authorization: string;
|
||||
readonly accountId: string;
|
||||
readonly apiPath: string;
|
||||
}
|
||||
|
||||
interface SetResult {
|
||||
readonly created?: Record<string, { readonly id?: string } | null>;
|
||||
readonly notCreated?: Record<string, JmapError>;
|
||||
readonly updated?: Record<string, unknown>;
|
||||
readonly notUpdated?: Record<string, JmapError>;
|
||||
readonly destroyed?: string[];
|
||||
readonly notDestroyed?: Record<string, JmapError>;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
readonly ids?: string[];
|
||||
}
|
||||
|
||||
interface GetResult<T> {
|
||||
readonly list?: T[];
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** 엔진의 SetError → 도구의 까닭 (종류만 — 설명 문장은 넣지 않는다) */
|
||||
function refusal(error: JmapError | undefined): OpsEngineError {
|
||||
const type = error?.type ?? 'unknown';
|
||||
if (type === 'invalidProperties' && error?.properties?.includes('secret')) {
|
||||
return new OpsEngineError('weak-password', type);
|
||||
}
|
||||
if (type === 'primaryKeyViolation' || type === 'alreadyExists') {
|
||||
return new OpsEngineError('exists', type);
|
||||
}
|
||||
if (type === 'notFound') return new OpsEngineError('not-found', type);
|
||||
return new OpsEngineError('refused', type);
|
||||
}
|
||||
|
||||
/** 계정 권한 칸에서 로그인 권한이 빠졌는가 */
|
||||
function isLocked(permissions: JmapPermissions | undefined): boolean {
|
||||
const type = permissions?.['@type'];
|
||||
if (type === 'Merge') {
|
||||
return permissions?.disabledPermissions?.authenticate === true;
|
||||
}
|
||||
if (type === 'Replace')
|
||||
return permissions?.permissions?.authenticate !== true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 비밀번호 자격(@type Password)의 id — 계정을 만들 때 "0" 이다 */
|
||||
function passwordCredentialId(account: JmapAccount): string | null {
|
||||
for (const [id, credential] of Object.entries(account.credentials ?? {})) {
|
||||
if (credential?.['@type'] === 'Password') return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 권한을 빼거나 되돌린 권한 칸 — 상속(Inherit)이면 Merge 로 빼고, Merge 면 그 안에서 바꾼다.
|
||||
* 되돌린 뒤 Merge 에 남은 것이 없으면 상속으로 돌린다. 직접 정한 권한(Replace)은 바꾸지 않는다
|
||||
*/
|
||||
export function permissionsWithLogin(
|
||||
permissions: JmapPermissions | undefined,
|
||||
enabled: boolean,
|
||||
): JmapPermissions | 'unchanged' {
|
||||
const type = permissions?.['@type'] ?? 'Inherit';
|
||||
if (type === 'Replace') {
|
||||
throw new OpsEngineError('custom-permissions');
|
||||
}
|
||||
const enabledPermissions: Record<string, boolean> = {};
|
||||
const disabledPermissions: Record<string, boolean> = {};
|
||||
if (type === 'Merge') {
|
||||
for (const [name, on] of Object.entries(
|
||||
permissions?.enabledPermissions ?? {},
|
||||
)) {
|
||||
if (on === true) enabledPermissions[name] = true;
|
||||
}
|
||||
for (const [name, on] of Object.entries(
|
||||
permissions?.disabledPermissions ?? {},
|
||||
)) {
|
||||
if (on === true) disabledPermissions[name] = true;
|
||||
}
|
||||
}
|
||||
const lockedNow = disabledPermissions.authenticate === true;
|
||||
if (enabled ? !lockedNow : lockedNow) return 'unchanged';
|
||||
if (enabled) {
|
||||
delete disabledPermissions.authenticate;
|
||||
} else {
|
||||
delete enabledPermissions.authenticate;
|
||||
disabledPermissions.authenticate = true;
|
||||
}
|
||||
if (
|
||||
Object.keys(enabledPermissions).length === 0 &&
|
||||
Object.keys(disabledPermissions).length === 0
|
||||
) {
|
||||
return { '@type': 'Inherit' };
|
||||
}
|
||||
return { '@type': 'Merge', enabledPermissions, disabledPermissions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart 운영 어댑터 — OpsEngine 의 구현 (13단계 — 계약 6절 "운영 도구").
|
||||
* - 관리자 Basic: `x:Account` · `x:MailingList` · `x:Domain`(계정을 만들 도메인 id) 관리 API 에만 — 비밀번호는 이 객체의 메모리에만, 로그에 남기지 않는다
|
||||
* - 게이트웨이 대리 접근: 앱 비밀번호(JmapAccountSecurityProvider) · 잠금 Sieve(JmapFilterScriptStore — 직원의 필터 · 부재중과 한 스크립트.
|
||||
* 엔진은 계정마다 스크립트 하나만 켠다 — 따로 스크립트를 켜면 직원의 필터 · 부재중 · 차단이 꺼진다, 2026-11-02 다시 확인)
|
||||
* 엔진 동작(2026-11-02 개발 엔진 확인): 만들 때 만료를 함께 받는다 · 관리자의 비밀번호 바꾸기는 OTP 를 남긴다 ·
|
||||
* OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapOpsEngine implements OpsEngine {
|
||||
private admin: AdminSession | null = null;
|
||||
private domainId: string | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
@Inject(OPS_ADMIN_ACCOUNT) private readonly adminAccount: string,
|
||||
private readonly accountSecurity: JmapAccountSecurityProvider,
|
||||
private readonly filterScripts: JmapFilterScriptStore,
|
||||
) {}
|
||||
|
||||
async signIn(adminPassword: string): Promise<OpsSignInResult> {
|
||||
const authorization = basicAuthorization(this.adminAccount, adminPassword);
|
||||
const reply = await this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization } },
|
||||
async (response) => {
|
||||
if (
|
||||
response.status === HTTP_UNAUTHORIZED ||
|
||||
response.status === HTTP_FORBIDDEN
|
||||
) {
|
||||
await discard(response);
|
||||
return 'rejected' as const;
|
||||
}
|
||||
if (response.status === HTTP_PAYMENT_REQUIRED) {
|
||||
await discard(response);
|
||||
return 'two-factor' as const;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await discard(response);
|
||||
throw new OpsEngineError('unavailable', `http-${response.status}`);
|
||||
}
|
||||
return (await readJson(response)) as JmapSessionResource | null;
|
||||
},
|
||||
);
|
||||
if (reply === 'rejected' || reply === 'two-factor') return reply;
|
||||
const session = reply;
|
||||
const accountId = session?.primaryAccounts?.[MANAGEMENT_CAPABILITY];
|
||||
if (!accountId || !session?.apiUrl) return 'not-admin';
|
||||
this.admin = {
|
||||
authorization,
|
||||
accountId,
|
||||
apiPath: toRelativeReference(session.apiUrl),
|
||||
};
|
||||
// 관리 권한 — 데이터를 읽지 않는 계정 목록 호출 하나 (직원이면 forbidden)
|
||||
const [probe] = await this.call([
|
||||
['x:Account/get', { accountId, ids: [], properties: ['name'] }, 'p'],
|
||||
]);
|
||||
if (!probe || probe[0] === 'error') {
|
||||
this.admin = null;
|
||||
return 'not-admin';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
isEmployeeAddress(email: string): boolean {
|
||||
return this.isEmployee(email.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async findEmployee(email: string): Promise<EngineEmployee | null> {
|
||||
const address = email.trim().toLowerCase();
|
||||
if (!this.isEmployee(address)) return null;
|
||||
const account = await this.findAccount(address);
|
||||
return account ? this.toEmployee(account) : null;
|
||||
}
|
||||
|
||||
async listEmployees(): Promise<EngineEmployee[]> {
|
||||
const accounts = await this.getAll<JmapAccount>(
|
||||
'Account',
|
||||
ACCOUNT_PROPERTIES,
|
||||
);
|
||||
return accounts
|
||||
.map((account) => this.toEmployee(account))
|
||||
.filter((employee): employee is EngineEmployee => employee !== null)
|
||||
.filter((employee) => this.isEmployee(employee.email))
|
||||
.sort((a, b) => a.email.localeCompare(b.email));
|
||||
}
|
||||
|
||||
async addressInUse(email: string): Promise<boolean> {
|
||||
const address = email.trim().toLowerCase();
|
||||
if (await this.findAccount(address)) return true;
|
||||
const lists = await this.listMailingLists();
|
||||
return lists.some((list) => list.email === address);
|
||||
}
|
||||
|
||||
async createEmployee(input: {
|
||||
readonly localPart: string;
|
||||
readonly name: string;
|
||||
readonly quotaBytes: number | null;
|
||||
readonly tempPassword: string;
|
||||
}): Promise<EngineEmployee> {
|
||||
const domainId = await this.requireDomainId();
|
||||
const result = await this.set('Account', {
|
||||
create: {
|
||||
a: {
|
||||
'@type': 'User',
|
||||
name: input.localPart,
|
||||
domainId,
|
||||
description: input.name,
|
||||
roles: { '@type': 'User' },
|
||||
credentials: {
|
||||
0: {
|
||||
'@type': 'Password',
|
||||
secret: input.tempPassword,
|
||||
expiresAt: this.expiredAt(),
|
||||
},
|
||||
},
|
||||
...(input.quotaBytes !== null
|
||||
? { quotas: { maxDiskQuota: input.quotaBytes } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const failure = result.notCreated?.a;
|
||||
if (failure) throw refusal(failure);
|
||||
const id = result.created?.a?.id;
|
||||
if (!id) throw new OpsEngineError('refused', 'no-created-id');
|
||||
const created = await this.getAccounts([id]);
|
||||
const employee = created[0] ? this.toEmployee(created[0]) : null;
|
||||
if (!employee) throw new OpsEngineError('refused', 'created-unreadable');
|
||||
return employee;
|
||||
}
|
||||
|
||||
async resetPassword(id: string, tempPassword: string): Promise<void> {
|
||||
const credentialId = await this.requirePasswordCredential(id);
|
||||
await this.update('Account', id, {
|
||||
[`credentials/${credentialId}/secret`]: tempPassword,
|
||||
[`credentials/${credentialId}/expiresAt`]: this.expiredAt(),
|
||||
});
|
||||
}
|
||||
|
||||
async removeTwoFactor(id: string): Promise<void> {
|
||||
const credentialId = await this.requirePasswordCredential(id);
|
||||
await this.update('Account', id, {
|
||||
[`credentials/${credentialId}/otpAuth`]: null,
|
||||
});
|
||||
}
|
||||
|
||||
async setLoginEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
const [account] = await this.getAccounts([id]);
|
||||
if (!account) throw new OpsEngineError('not-found');
|
||||
const next = permissionsWithLogin(account.permissions, enabled);
|
||||
if (next === 'unchanged') return;
|
||||
await this.update('Account', id, { permissions: next });
|
||||
}
|
||||
|
||||
async deleteAccount(id: string): Promise<void> {
|
||||
const result = await this.set('Account', { destroy: [id] });
|
||||
const failure = result.notDestroyed?.[id];
|
||||
if (failure && failure.type !== 'notFound') throw refusal(failure);
|
||||
}
|
||||
|
||||
async listMailingLists(): Promise<EngineMailingList[]> {
|
||||
const lists = await this.getAll<JmapMailingList>('MailingList', null);
|
||||
return lists
|
||||
.map((list) => this.toMailingList(list))
|
||||
.filter((list): list is EngineMailingList => list !== null)
|
||||
.sort((a, b) => a.email.localeCompare(b.email));
|
||||
}
|
||||
|
||||
async createMailingList(input: {
|
||||
readonly localPart: string;
|
||||
readonly name: string;
|
||||
readonly members: readonly string[];
|
||||
}): Promise<EngineMailingList> {
|
||||
const domainId = await this.requireDomainId();
|
||||
const result = await this.set('MailingList', {
|
||||
create: {
|
||||
l: {
|
||||
'@type': 'MailingList',
|
||||
name: input.localPart,
|
||||
domainId,
|
||||
description: input.name,
|
||||
recipients: Object.fromEntries(
|
||||
input.members.map((member) => [member, true]),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
const failure = result.notCreated?.l;
|
||||
if (failure) throw refusal(failure);
|
||||
const id = result.created?.l?.id;
|
||||
const created = (await this.listMailingLists()).find(
|
||||
(list) => list.id === id,
|
||||
);
|
||||
if (!created) throw new OpsEngineError('refused', 'created-unreadable');
|
||||
return created;
|
||||
}
|
||||
|
||||
async setMailingListMember(
|
||||
id: string,
|
||||
member: string,
|
||||
included: boolean,
|
||||
): Promise<void> {
|
||||
await this.update('MailingList', id, {
|
||||
[`recipients/${member}`]: included ? true : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 게이트웨이 대리 접근 (직원의 앱 비밀번호 · Sieve) ──────────────────────────
|
||||
|
||||
deleteAppPasswords(email: string): Promise<number> {
|
||||
return this.accountSecurity.deleteAllAppPasswords(email);
|
||||
}
|
||||
|
||||
async getLockRules(email: string): Promise<LockRulesState> {
|
||||
const [filters, vacation] = await Promise.all([
|
||||
this.filterScripts.getFilters(email),
|
||||
this.filterScripts.getVacation(email),
|
||||
]);
|
||||
const rule = filters.rules.find(isLockRule);
|
||||
return {
|
||||
forwardTo: rule?.actions.forwardTo ?? null,
|
||||
autoReply: vacation.isEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async applyLockRules(email: string, rules: LockRules): Promise<void> {
|
||||
if (rules.forwardTo !== null) {
|
||||
const filters = await this.filterScripts.getFilters(email);
|
||||
const others = filters.rules.filter((rule) => !isLockRule(rule));
|
||||
if (others.length + 1 > MAX_MAIL_FILTER_RULES) {
|
||||
throw new OpsEngineError('too-many-rules');
|
||||
}
|
||||
// 맨 앞에 — 엔진은 한 통에 리다이렉트 하나만 한다(뒤의 전달은 버린다). 사본은 그 직원의 받은편지함에도 남는다
|
||||
const lockRule: MailFilterRule = {
|
||||
id: randomUUID(),
|
||||
name: LOCK_FORWARD_RULE_NAME,
|
||||
isEnabled: true,
|
||||
match: 'always',
|
||||
conditions: [],
|
||||
actions: {
|
||||
moveToMailboxId: null,
|
||||
markAsRead: false,
|
||||
flag: false,
|
||||
forwardTo: rules.forwardTo,
|
||||
},
|
||||
};
|
||||
await this.filterScripts.setFilters(email, {
|
||||
rules: [lockRule, ...others],
|
||||
version: filters.version,
|
||||
});
|
||||
}
|
||||
if (rules.autoReply !== null) {
|
||||
await this.filterScripts.setVacation(email, {
|
||||
isEnabled: true,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: rules.autoReply.subject,
|
||||
textBody: rules.autoReply.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async clearLockRules(email: string): Promise<void> {
|
||||
const filters = await this.filterScripts.getFilters(email);
|
||||
if (filters.rules.some(isLockRule)) {
|
||||
await this.filterScripts.setFilters(email, {
|
||||
rules: filters.rules.filter((rule) => !isLockRule(rule)),
|
||||
version: filters.version,
|
||||
});
|
||||
}
|
||||
const vacation = await this.filterScripts.getVacation(email);
|
||||
if (vacation.isEnabled) {
|
||||
await this.filterScripts.setVacation(email, {
|
||||
isEnabled: false,
|
||||
fromDate: vacation.fromDate,
|
||||
toDate: vacation.toDate,
|
||||
subject: vacation.subject,
|
||||
textBody: vacation.textBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 관리 API ───────────────────────────────────────────────────────────────
|
||||
|
||||
private isEmployee(address: string): boolean {
|
||||
const { domain, gatewayUser, blockedAccounts } = this.settings;
|
||||
return (
|
||||
domain !== null &&
|
||||
isEmployeeAddress(address, domain, gatewayUser, blockedAccounts)
|
||||
);
|
||||
}
|
||||
|
||||
private expiredAt(): string {
|
||||
return new Date(Date.now() - EXPIRED_AT_OFFSET_MS)
|
||||
.toISOString()
|
||||
.replace(/\.\d+Z$/, 'Z');
|
||||
}
|
||||
|
||||
private toEmployee(account: JmapAccount): EngineEmployee | null {
|
||||
const id = account.id;
|
||||
const email = asString(account.emailAddress)?.toLowerCase() ?? null;
|
||||
if (!id || email === null) return null;
|
||||
// 직원은 User 계정뿐이다 — Group(공유 메일함)은 현황 · 명령의 대상이 아니다. 칸이 없으면(옛 엔진) User 로 본다
|
||||
const type = asString(account['@type']);
|
||||
if (type !== null && type !== 'User') return null;
|
||||
const credentials = Object.values(account.credentials ?? {});
|
||||
const password = credentials.find(
|
||||
(credential) => credential?.['@type'] === 'Password',
|
||||
);
|
||||
const expiresAt = asString(password?.expiresAt);
|
||||
const description = asString(account.description)?.trim();
|
||||
return {
|
||||
id,
|
||||
email,
|
||||
name: description || fallbackDisplayName(email),
|
||||
locked: isLocked(account.permissions),
|
||||
customPermissions: account.permissions?.['@type'] === 'Replace',
|
||||
passwordExpired:
|
||||
expiresAt !== null && Date.parse(expiresAt) <= Date.now(),
|
||||
twoFactorEnabled:
|
||||
password?.otpAuth !== undefined && password.otpAuth !== null,
|
||||
appPasswords: credentials.filter(
|
||||
(credential) => credential?.['@type'] === 'AppPassword',
|
||||
).length,
|
||||
usedBytes: asNumber(account.usedDiskQuota),
|
||||
quotaBytes: asNumber(account.quotas?.maxDiskQuota),
|
||||
};
|
||||
}
|
||||
|
||||
private toMailingList(list: JmapMailingList): EngineMailingList | null {
|
||||
const email = asString(list.emailAddress)?.toLowerCase() ?? null;
|
||||
if (!list.id || email === null) return null;
|
||||
return {
|
||||
id: list.id,
|
||||
email,
|
||||
name: asString(list.description) ?? asString(list.name) ?? email,
|
||||
members: Object.entries(list.recipients ?? {})
|
||||
.filter(([, on]) => on === true)
|
||||
.map(([address]) => address.toLowerCase())
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 주소로 계정 하나 — 로컬 이름으로 거른 뒤 주소가 정확히 같은 것만 */
|
||||
private async findAccount(address: string): Promise<JmapAccount | null> {
|
||||
const localPart = address.slice(0, address.indexOf('@'));
|
||||
const [query] = await this.call([
|
||||
[
|
||||
'x:Account/query',
|
||||
{
|
||||
accountId: this.session().accountId,
|
||||
filter: { name: localPart },
|
||||
limit: LIST_LIMIT,
|
||||
},
|
||||
'q',
|
||||
],
|
||||
]);
|
||||
const ids = this.expect<QueryResult>(query, 'x:Account/query').ids ?? [];
|
||||
if (ids.length === 0) return null;
|
||||
const accounts = await this.getAccounts(ids);
|
||||
return (
|
||||
accounts.find(
|
||||
(account) => asString(account.emailAddress)?.toLowerCase() === address,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private async getAccounts(ids: readonly string[]): Promise<JmapAccount[]> {
|
||||
const [get] = await this.call([
|
||||
[
|
||||
'x:Account/get',
|
||||
{
|
||||
accountId: this.session().accountId,
|
||||
ids,
|
||||
properties: ACCOUNT_PROPERTIES,
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
return this.expect<GetResult<JmapAccount>>(get, 'x:Account/get').list ?? [];
|
||||
}
|
||||
|
||||
private async getAll<T>(
|
||||
type: 'Account' | 'MailingList',
|
||||
properties: readonly string[] | null,
|
||||
): Promise<T[]> {
|
||||
const accountId = this.session().accountId;
|
||||
const responses = await this.call([
|
||||
[`x:${type}/query`, { accountId, limit: LIST_LIMIT }, 'q'],
|
||||
[
|
||||
`x:${type}/get`,
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: `x:${type}/query`, path: '/ids' },
|
||||
...(properties ? { properties } : {}),
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
const get = responses.find((invocation) => invocation[2] === 'g');
|
||||
return this.expect<GetResult<T>>(get, `x:${type}/get`).list ?? [];
|
||||
}
|
||||
|
||||
private async requireDomainId(): Promise<string> {
|
||||
if (this.domainId) return this.domainId;
|
||||
const domain = this.settings.domain;
|
||||
const accountId = this.session().accountId;
|
||||
const responses = await this.call([
|
||||
['x:Domain/query', { accountId, limit: LIST_LIMIT }, 'q'],
|
||||
[
|
||||
'x:Domain/get',
|
||||
{
|
||||
accountId,
|
||||
'#ids': { resultOf: 'q', name: 'x:Domain/query', path: '/ids' },
|
||||
properties: ['name'],
|
||||
},
|
||||
'g',
|
||||
],
|
||||
]);
|
||||
const get = responses.find((invocation) => invocation[2] === 'g');
|
||||
const found = (
|
||||
this.expect<GetResult<{ id?: string; name?: unknown }>>(
|
||||
get,
|
||||
'x:Domain/get',
|
||||
).list ?? []
|
||||
).find((item) => asString(item.name)?.toLowerCase() === domain);
|
||||
if (!found?.id) throw new OpsEngineError('not-found', 'domain');
|
||||
this.domainId = found.id;
|
||||
return found.id;
|
||||
}
|
||||
|
||||
private async requirePasswordCredential(id: string): Promise<string> {
|
||||
const [account] = await this.getAccounts([id]);
|
||||
if (!account) throw new OpsEngineError('not-found');
|
||||
const credentialId = passwordCredentialId(account);
|
||||
if (credentialId === null) {
|
||||
throw new OpsEngineError('not-found', 'password-credential');
|
||||
}
|
||||
return credentialId;
|
||||
}
|
||||
|
||||
private async update(
|
||||
type: 'Account' | 'MailingList',
|
||||
id: string,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const result = await this.set(type, { update: { [id]: patch } });
|
||||
const failure = result.notUpdated?.[id];
|
||||
if (failure) throw refusal(failure);
|
||||
if (!result.updated || !(id in result.updated)) {
|
||||
throw new OpsEngineError('refused', 'not-updated');
|
||||
}
|
||||
}
|
||||
|
||||
private async set(
|
||||
type: 'Account' | 'MailingList',
|
||||
args: Record<string, unknown>,
|
||||
): Promise<SetResult> {
|
||||
const [invocation] = await this.call([
|
||||
[`x:${type}/set`, { accountId: this.session().accountId, ...args }, 's'],
|
||||
]);
|
||||
return this.expect<SetResult>(invocation, `x:${type}/set`);
|
||||
}
|
||||
|
||||
private expect<T>(invocation: JmapInvocation | undefined, method: string): T {
|
||||
if (!invocation) throw new OpsEngineError('unavailable', 'no-response');
|
||||
const [name, args] = invocation;
|
||||
if (name === 'error') {
|
||||
// 메서드의 forbidden 은 그 일을 할 권한이 없다는 뜻이다(예: Group 계정 만들기) — 로그인이 끊긴 것은 HTTP 401 · 403 뿐이다(call)
|
||||
throw new OpsEngineError(
|
||||
'refused',
|
||||
(args as JmapError).type ?? 'unknown',
|
||||
);
|
||||
}
|
||||
if (name !== method) throw new OpsEngineError('unavailable', String(name));
|
||||
return args as T;
|
||||
}
|
||||
|
||||
private session(): AdminSession {
|
||||
if (!this.admin) throw new OpsEngineError('not-signed-in');
|
||||
return this.admin;
|
||||
}
|
||||
|
||||
private async call(methodCalls: JmapInvocation[]): Promise<JmapInvocation[]> {
|
||||
const session = this.session();
|
||||
const body = await this.exchange(
|
||||
session.apiPath,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: session.authorization,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
using: [CORE_CAPABILITY, MANAGEMENT_CAPABILITY],
|
||||
methodCalls,
|
||||
}),
|
||||
},
|
||||
async (response) => {
|
||||
if (
|
||||
response.status === HTTP_UNAUTHORIZED ||
|
||||
response.status === HTTP_FORBIDDEN
|
||||
) {
|
||||
await discard(response);
|
||||
this.admin = null;
|
||||
throw new OpsEngineError('not-signed-in', `http-${response.status}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
await discard(response);
|
||||
throw new OpsEngineError('unavailable', `http-${response.status}`);
|
||||
}
|
||||
return (await readJson(response)) as JmapResponseBody | null;
|
||||
},
|
||||
);
|
||||
if (!body || !Array.isArray(body.methodResponses)) {
|
||||
throw new OpsEngineError('unavailable', 'malformed-response');
|
||||
}
|
||||
return body.methodResponses;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진에 한 번 — 머리글을 받고 본문을 다 읽을 때까지 **한 제한 시간** 안에서(머리글 뒤 본문이 멈춰도 도구가 기다리지 않게),
|
||||
* 다른 곳으로 보내지 않는다(인증 머리글이 다른 호스트로 가지 않게)
|
||||
*/
|
||||
private async exchange<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
handle: (response: Response) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!isMailEngineConfigured(this.settings)) {
|
||||
throw new OpsEngineError('unavailable', 'mail-not-configured');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
ADMIN_REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(`${this.settings.jmapBaseUrl}${path}`, {
|
||||
...init,
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return await handle(response);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof OpsEngineError ||
|
||||
error instanceof BusinessException
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new OpsEngineError(
|
||||
'unavailable',
|
||||
controller.signal.aborted ? 'timeout' : 'network',
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 읽지 않을 본문을 버린다 (연결을 돌려준다) */
|
||||
async function discard(response: Response): Promise<void> {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 JSON — 모양이 JSON 이 아니면 null(부르는 쪽이 malformed 로). 읽다가 끊기거나 시간이 다 되면 던진다(exchange 가 timeout · network 로)
|
||||
*/
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLockRule(rule: MailFilterRule): boolean {
|
||||
return rule.name === LOCK_FORWARD_RULE_NAME;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { SystemMailMessage } from '@/modules/system-mail/system-mailbox.port';
|
||||
import type { JmapClient } from './jmap.client';
|
||||
import { JmapSystemMailbox, buildSystemMailRaw } from './jmap-system-mailbox';
|
||||
import { decodeHeaderText } from './mime-text';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
|
||||
const message: SystemMailMessage = {
|
||||
kind: 'password-reset',
|
||||
to: { email: ARA, name: '김아라' },
|
||||
subject: '관리자가 비밀번호를 재설정했습니다',
|
||||
html: '<div><p>안녕하세요 — 알림</p></div>',
|
||||
text: '안녕하세요 — 알림\n\n끝',
|
||||
};
|
||||
|
||||
const settings = {
|
||||
domain: 'arasystem.test',
|
||||
} as unknown as MailSettings;
|
||||
|
||||
/** 원문의 머리글 한 줄 */
|
||||
function header(raw: string, name: string): string | undefined {
|
||||
const head = raw.slice(0, raw.indexOf('\r\n\r\n'));
|
||||
return head
|
||||
.split('\r\n')
|
||||
.find((line) => line.toLowerCase().startsWith(`${name.toLowerCase()}:`))
|
||||
?.slice(name.length + 1)
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** multipart 의 부분 하나를 풀어 본문 글로 */
|
||||
function partBody(raw: string, type: string): string {
|
||||
const start = raw.indexOf(`Content-Type: ${type}`);
|
||||
const body = raw.slice(raw.indexOf('\r\n\r\n', start) + 4);
|
||||
const end = body.indexOf('\r\n--');
|
||||
return Buffer.from(
|
||||
body.slice(0, end).replace(/\r\n/g, ''),
|
||||
'base64',
|
||||
).toString('utf8');
|
||||
}
|
||||
|
||||
describe('buildSystemMailRaw — 알림 메일 원문 (13단계)', () => {
|
||||
const raw = buildSystemMailRaw({
|
||||
from: 'postmaster@arasystem.test',
|
||||
message,
|
||||
date: new Date('2026-10-30T06:20:00Z'),
|
||||
messageId: 'ara-notice-1@arasystem.test',
|
||||
boundary: 'ara-notice-b',
|
||||
});
|
||||
|
||||
it('보낸 사람 ARA SYSTEM <postmaster@도메인>, 받는 사람 이름 <주소>, 자동 발송 표시', () => {
|
||||
expect(header(raw, 'From')).toBe('ARA SYSTEM <postmaster@arasystem.test>');
|
||||
expect(decodeHeaderText(header(raw, 'To') ?? '')).toBe(`김아라 <${ARA}>`);
|
||||
expect(decodeHeaderText(header(raw, 'Subject') ?? '')).toBe(
|
||||
'관리자가 비밀번호를 재설정했습니다',
|
||||
);
|
||||
expect(header(raw, 'Date')).toBe('Fri, 30 Oct 2026 06:20:00 +0000');
|
||||
expect(header(raw, 'Message-ID')).toBe('<ara-notice-1@arasystem.test>');
|
||||
expect(header(raw, 'Auto-Submitted')).toBe('auto-generated');
|
||||
expect(header(raw, 'X-Ara-Notice')).toBe('password-reset');
|
||||
expect(header(raw, 'Content-Type')).toBe(
|
||||
'multipart/alternative; boundary="ara-notice-b"',
|
||||
);
|
||||
});
|
||||
|
||||
it('머리는 ASCII 뿐이고(한글은 encoded-word), 텍스트 · HTML 두 부분이 같은 글을 담는다', () => {
|
||||
const head = raw.slice(0, raw.indexOf('\r\n\r\n'));
|
||||
expect(head).toMatch(/^[\x20-\x7e\r\n]*$/);
|
||||
expect(partBody(raw, 'text/plain')).toBe(
|
||||
message.text.replace(/\n/g, '\r\n'),
|
||||
);
|
||||
expect(partBody(raw, 'text/html')).toBe(message.html);
|
||||
expect(raw.endsWith('--ara-notice-b--\r\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('주소에 줄바꿈 · 꺾쇠가 있으면 만들지 않는다 (머리글이 깨지지 않게)', () => {
|
||||
for (const email of [
|
||||
'a@b.test\r\nBcc: x@y.test',
|
||||
'<a@b.test>',
|
||||
'a b@c.test',
|
||||
]) {
|
||||
expect(() =>
|
||||
buildSystemMailRaw({
|
||||
from: 'postmaster@arasystem.test',
|
||||
message: { ...message, to: { email, name: 'x' } },
|
||||
date: new Date(),
|
||||
messageId: 'm@x',
|
||||
boundary: 'b',
|
||||
}),
|
||||
).toThrow(BusinessException);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('JmapSystemMailbox — 받은편지함에 넣기 (13단계)', () => {
|
||||
let jmap: {
|
||||
call: jest.Mock;
|
||||
upload: jest.Mock;
|
||||
getSession: jest.Mock;
|
||||
};
|
||||
let mailbox: JmapSystemMailbox;
|
||||
|
||||
beforeEach(() => {
|
||||
jmap = {
|
||||
call: jest.fn(),
|
||||
upload: jest.fn().mockResolvedValue({
|
||||
blobId: 'blob-1',
|
||||
type: 'message/rfc822',
|
||||
size: 1000,
|
||||
}),
|
||||
getSession: jest.fn().mockResolvedValue({ username: ARA }),
|
||||
};
|
||||
mailbox = new JmapSystemMailbox(jmap as unknown as JmapClient, settings);
|
||||
});
|
||||
|
||||
it('원문을 올리고 받은편지함에 안 읽음 · 받은 시각 지금으로 Email/import 한다 → 메일 id', async () => {
|
||||
jmap.call
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
'Mailbox/get',
|
||||
{
|
||||
list: [
|
||||
{ id: 'm-sent', role: 'sent' },
|
||||
{ id: 'm-inbox', role: 'inbox' },
|
||||
],
|
||||
},
|
||||
'm',
|
||||
],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
['Email/import', { created: { notice: { id: 'e-1' } } }, 'i'],
|
||||
]);
|
||||
|
||||
await expect(mailbox.deliver(ARA, message)).resolves.toBe('e-1');
|
||||
|
||||
expect(jmap.upload).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
expect.any(Blob),
|
||||
'message/rfc822',
|
||||
);
|
||||
const build = (jmap.call.mock.calls[1] as unknown[])[1] as (
|
||||
id: string,
|
||||
) => unknown[];
|
||||
const [[method, args]] = build('acc') as [
|
||||
[string, { emails: Record<string, Record<string, unknown>> }],
|
||||
];
|
||||
expect(method).toBe('Email/import');
|
||||
expect(args.emails.notice).toMatchObject({
|
||||
blobId: 'blob-1',
|
||||
mailboxIds: { 'm-inbox': true },
|
||||
keywords: {},
|
||||
});
|
||||
expect(args.emails.notice.receivedAt).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('엔진이 넣지 않으면(notCreated) 코드 있는 실패로 던진다', async () => {
|
||||
jmap.call
|
||||
.mockResolvedValueOnce([
|
||||
['Mailbox/get', { list: [{ id: 'm-inbox', role: 'inbox' }] }, 'm'],
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
'Email/import',
|
||||
{ notCreated: { notice: { type: 'overQuota' } } },
|
||||
'i',
|
||||
],
|
||||
]);
|
||||
|
||||
await expect(mailbox.deliver(ARA, message)).rejects.toBeInstanceOf(
|
||||
BusinessException,
|
||||
);
|
||||
});
|
||||
|
||||
it('받은편지함이 없으면 MAIL_001 — 올리지 않는다', async () => {
|
||||
jmap.call.mockResolvedValueOnce([['Mailbox/get', { list: [] }, 'm']]);
|
||||
await expect(mailbox.deliver(ARA, message)).rejects.toMatchObject({
|
||||
code: 'MAIL_001',
|
||||
});
|
||||
expect(jmap.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지우기는 방금 넣은 메일 하나만 destroy — 이미 없으면 그대로 끝난다', async () => {
|
||||
jmap.call.mockResolvedValueOnce([
|
||||
['Email/set', { notDestroyed: { 'e-1': { type: 'notFound' } } }, 'd'],
|
||||
]);
|
||||
await expect(mailbox.remove(ARA, 'e-1')).resolves.toBeUndefined();
|
||||
const build = (jmap.call.mock.calls[0] as unknown[])[1] as (
|
||||
id: string,
|
||||
) => unknown[];
|
||||
expect(build('acc')).toEqual([
|
||||
['Email/set', { accountId: 'acc', destroy: ['e-1'] }, 'd'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('표시 이름 — 직원 주소의 identity 이름, 없거나 읽지 못하면 @ 앞', async () => {
|
||||
jmap.call.mockResolvedValueOnce([
|
||||
['Identity/get', { list: [{ email: ARA, name: '김아라' }] }, 'i'],
|
||||
]);
|
||||
await expect(mailbox.displayNameOf(ARA)).resolves.toBe('김아라');
|
||||
|
||||
jmap.call.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
await expect(mailbox.displayNameOf(ARA)).resolves.toBe('ara.kim');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
SystemMailbox,
|
||||
SystemMailMessage,
|
||||
} from '@/modules/system-mail/system-mailbox.port';
|
||||
import { JmapClient } from './jmap.client';
|
||||
import {
|
||||
fallbackDisplayName,
|
||||
pickIdentity,
|
||||
toJmapUtcDate,
|
||||
} from './jmap.mapper';
|
||||
import { expectResult, firstSetError } from './jmap-results';
|
||||
import { jmapFailure } from './jmap.errors';
|
||||
import type {
|
||||
JmapGetResult,
|
||||
JmapIdentity,
|
||||
JmapMailbox,
|
||||
JmapSetResult,
|
||||
} from './jmap.types';
|
||||
import { base64TextBody, encodeHeaderWords } from './mime-text';
|
||||
|
||||
/** 보낸 사람 표시 이름 (계약 6절 — `ARA SYSTEM <postmaster@<MAIL_DOMAIN>>`) */
|
||||
export const SYSTEM_SENDER_NAME = 'ARA SYSTEM';
|
||||
/** 보낸 사람 주소의 로컬 부분 */
|
||||
export const SYSTEM_SENDER_LOCAL_PART = 'postmaster';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
/** 원문의 형식 — Email/import 는 올린 blob 을 메일 원문으로 읽는다 */
|
||||
const MESSAGE_TYPE = 'message/rfc822';
|
||||
/** 알림 메일을 엔진에 넣을 때의 이름 */
|
||||
const IMPORT_KEY = 'notice';
|
||||
|
||||
/** 원문의 머리 · 본문을 만드는 데 쓰는 값 */
|
||||
export interface SystemMailRawInput {
|
||||
readonly from: string;
|
||||
readonly message: SystemMailMessage;
|
||||
readonly date: Date;
|
||||
readonly messageId: string;
|
||||
readonly boundary: string;
|
||||
}
|
||||
|
||||
/** 머리글 값에 줄바꿈 · 꺾쇠가 들어가지 못하게 — 주소는 서버가 아는 직원 주소지만 원문을 깨뜨리지 않게 한 번 더 본다 */
|
||||
const SAFE_ADDRESS = /^[^\s<>"\\,;:@]+@[^\s<>"\\,;:@]+$/;
|
||||
|
||||
/** RFC 5322 날짜 — 'Mon, 02 Nov 2026 02:40:00 +0000' */
|
||||
function rfc5322Date(date: Date): string {
|
||||
return date.toUTCString().replace(/GMT$/, '+0000');
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 메일의 원문 (순수 함수) — multipart/alternative(텍스트 · HTML, 둘 다 UTF-8 base64).
|
||||
* - 받는 사람 이름 · 제목은 RFC 2047 encoded-word(ASCII 만 남는다 — 머리글에 줄바꿈이 끼지 못한다)
|
||||
* - `Auto-Submitted: auto-generated`(RFC 3834) — 부재중 자동 응답 · 다른 자동 응답이 이 메일에 답하지 않게
|
||||
* - `X-Ara-Notice` — 알림의 종류 (사람은 보지 않는다 — 검색 · 문제 확인용)
|
||||
*/
|
||||
export function buildSystemMailRaw(input: SystemMailRawInput): string {
|
||||
const { from, message, date, messageId, boundary } = input;
|
||||
if (!SAFE_ADDRESS.test(from) || !SAFE_ADDRESS.test(message.to.email)) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'system-mail-address-invalid' },
|
||||
});
|
||||
}
|
||||
const headers = [
|
||||
`From: ${SYSTEM_SENDER_NAME} <${from}>`,
|
||||
`To: ${encodeHeaderWords(message.to.name)} <${message.to.email}>`,
|
||||
`Subject: ${encodeHeaderWords(message.subject)}`,
|
||||
`Date: ${rfc5322Date(date)}`,
|
||||
`Message-ID: <${messageId}>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Auto-Submitted: auto-generated',
|
||||
`X-Ara-Notice: ${message.kind}`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
];
|
||||
const part = (type: string, body: string) =>
|
||||
[
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${type}; charset="utf-8"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
base64TextBody(body),
|
||||
].join(CRLF);
|
||||
return [
|
||||
...headers,
|
||||
'',
|
||||
part('text/plain', message.text),
|
||||
part('text/html', message.html),
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join(CRLF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart(JMAP) 알림 메일 어댑터 — SystemMailbox 의 구현 (13단계 — 계약 6절 "받은편지함 알림 메일").
|
||||
* 게이트웨이 대리 접근으로 원문을 올리고(`message/rfc822`) `Email/import` 로 받은편지함에 넣는다 — 안 읽음(`keywords {}`) · 받은 시각 지금.
|
||||
* 엔진 안의 JMAP 생성이라 SPF · DKIM · 필터 · 스팸 판정을 거치지 않는다 (2026-10-31 개발 엔진 확인).
|
||||
* 그 직원의 비밀번호가 만료됐거나(메일 기능이 없다) 잠겼으면(대리 접근 403) 넣지 못한다 — 부르는 쪽이 차례를 지킨다(운영 도구).
|
||||
* 메일 내용은 로그 · 오류 context 에 남기지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class JmapSystemMailbox implements SystemMailbox {
|
||||
constructor(
|
||||
private readonly jmap: JmapClient,
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
) {}
|
||||
|
||||
async deliver(user: string, message: SystemMailMessage): Promise<string> {
|
||||
const { domain } = this.settings;
|
||||
if (!domain) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-not-configured' },
|
||||
});
|
||||
}
|
||||
const inboxId = await this.inboxOf(user);
|
||||
const raw = buildSystemMailRaw({
|
||||
from: `${SYSTEM_SENDER_LOCAL_PART}@${domain}`,
|
||||
message,
|
||||
date: new Date(),
|
||||
messageId: `ara-notice-${randomUUID()}@${domain}`,
|
||||
boundary: `ara-notice-${randomBytes(12).toString('hex')}`,
|
||||
});
|
||||
const blob = await this.jmap.upload(
|
||||
user,
|
||||
new Blob([raw], { type: MESSAGE_TYPE }),
|
||||
MESSAGE_TYPE,
|
||||
);
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
[
|
||||
'Email/import',
|
||||
{
|
||||
accountId,
|
||||
emails: {
|
||||
[IMPORT_KEY]: {
|
||||
blobId: blob.blobId,
|
||||
mailboxIds: { [inboxId]: true },
|
||||
keywords: {},
|
||||
receivedAt: toJmapUtcDate(new Date().toISOString()),
|
||||
},
|
||||
},
|
||||
},
|
||||
'i',
|
||||
],
|
||||
]);
|
||||
const result = expectResult<JmapSetResult>(responses, 'i', 'Email/import');
|
||||
const refusal = firstSetError(result.notCreated);
|
||||
if (refusal) throw jmapFailure(refusal, { method: 'Email/import' });
|
||||
const id = result.created?.[IMPORT_KEY]?.id;
|
||||
if (typeof id !== 'string' || id === '') {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'system-mail-import-unanswered' },
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 방금 넣은 알림만 지운다 — 이 요청이 만든 메일이라 조건 없이 지운다(nestjs 스킬 "조건 없는 destroy 는 방금 만든 메일에만") */
|
||||
async remove(user: string, emailId: string): Promise<void> {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Email/set', { accountId, destroy: [emailId] }, 'd'],
|
||||
]);
|
||||
const result = expectResult<JmapSetResult>(responses, 'd', 'Email/set');
|
||||
const refusal = Object.values(result.notDestroyed ?? {}).find(
|
||||
(error) => error?.type !== 'notFound',
|
||||
);
|
||||
if (refusal) throw jmapFailure(refusal, { method: 'Email/set' });
|
||||
}
|
||||
|
||||
async displayNameOf(user: string): Promise<string> {
|
||||
try {
|
||||
const session = await this.jmap.getSession(user);
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Identity/get', { accountId, ids: null }, 'i'],
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapIdentity>>(
|
||||
responses,
|
||||
'i',
|
||||
'Identity/get',
|
||||
);
|
||||
const name = pickIdentity(list, session.username)?.name?.trim();
|
||||
return name || fallbackDisplayName(user);
|
||||
} catch {
|
||||
return fallbackDisplayName(user);
|
||||
}
|
||||
}
|
||||
|
||||
private async inboxOf(user: string): Promise<string> {
|
||||
const responses = await this.jmap.call(user, (accountId) => [
|
||||
['Mailbox/get', { accountId, ids: null, properties: ['role'] }, 'm'],
|
||||
]);
|
||||
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
|
||||
responses,
|
||||
'm',
|
||||
'Mailbox/get',
|
||||
);
|
||||
const inbox = list.find((mailbox) => mailbox.role === 'inbox');
|
||||
if (!inbox?.id) {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'inbox-missing' },
|
||||
});
|
||||
}
|
||||
return inbox.id;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Cache } from 'cache-manager';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
PasswordExpiredException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { runWithRequestContext } from '@/shared/logger/request-context';
|
||||
import {
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
classifyPasswordChange,
|
||||
expandTemplate,
|
||||
toRelativeReference,
|
||||
isPasswordExpiredPermissions,
|
||||
} from './jmap.client';
|
||||
import type { JmapInvocation } from './jmap.types';
|
||||
|
||||
@@ -619,17 +624,15 @@ describe('JmapClient', () => {
|
||||
).resolves.toBe('outcome-unknown');
|
||||
});
|
||||
|
||||
it('엔진이 계정 관리 기능을 알려 주지 않으면 세션을 새로 받고, 그래도 없으면 MAIL_001 — 바꾸기를 보내지 않는다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(sessionBody))
|
||||
.mockResolvedValueOnce(json(sessionBody));
|
||||
it('엔진이 계정 관리 기능을 알려 주지 않으면(세션 자원을 새로 읽어도) MAIL_001 — 바꾸기를 보내지 않는다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
|
||||
expect(
|
||||
await codeOf(
|
||||
client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -719,6 +722,116 @@ describe('JmapClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('비밀번호가 만료된 계정 (13단계 — 계약 6절)', () => {
|
||||
/** 만료된 계정의 세션 자원 — 메일 기능이 없고 계정 관리 기능만 있다 (2026-11-02 개발 엔진) */
|
||||
const expiredSession = {
|
||||
...sessionBody,
|
||||
primaryAccounts: { 'urn:stalwart:jmap': 'x1' },
|
||||
};
|
||||
const EXPIRED_PERMISSIONS = [
|
||||
'emailReceive',
|
||||
'authenticateWithAlias',
|
||||
'authenticate',
|
||||
'sysAccountPasswordUpdate',
|
||||
'sysAccountPasswordGet',
|
||||
];
|
||||
|
||||
it('메일 기능이 없고 권한이 만료의 다섯뿐이면 PasswordExpiredException — 정식 주소와 @ 앞 이름, 세션은 기억하지 않는다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
json({ ...expiredSession, username: 'Ara.Kim@arasystem.test' }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({ permissions: EXPIRED_PERMISSIONS, edition: 'community' }),
|
||||
);
|
||||
|
||||
const failure = await client
|
||||
.getSession(ARA, { fresh: true })
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(PasswordExpiredException);
|
||||
// 메일 API 의 응답으로 나가면 401 — 화면이 곧 갱신해 새 비밀번호 단계(또는 세션 만료)로 간다
|
||||
expect((failure as PasswordExpiredException).code).toBe('AUTH_001');
|
||||
expect((failure as PasswordExpiredException).account).toBe(ARA);
|
||||
expect((failure as PasswordExpiredException).displayName).toBe('ara.kim');
|
||||
expect(requestAt(1).url).toBe('http://mail:8080/api/account');
|
||||
expect(decodeBasic(requestAt(1).authorization)).toBe(
|
||||
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
|
||||
);
|
||||
expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('메일 기능이 없지만 만료의 모양이 아니면(역할 설정 실수 등) 지금처럼 MAIL_001 — 만료로 보지 않는다', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(expiredSession))
|
||||
.mockResolvedValueOnce(json({ permissions: ['authenticate'] }));
|
||||
|
||||
const failure = await client
|
||||
.getSession(ARA, { fresh: true })
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect(failure).not.toBeInstanceOf(PasswordExpiredException);
|
||||
expect((failure as BusinessException).code).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('계정 정보를 읽지 못하면 만료로 보지 않고 MAIL_001', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(expiredSession))
|
||||
.mockResolvedValueOnce(json({ type: 'error' }, 500));
|
||||
|
||||
const failure = await client
|
||||
.getSession(ARA, { fresh: true })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).not.toBeInstanceOf(PasswordExpiredException);
|
||||
expect((failure as BusinessException).code).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('계정 관리 호출은 만료된 계정에도 열린다 — 메일 기능 없이 계정 관리 계정 id 로 (2단계 인증 상태 · 2단계 직원의 비밀번호 바꾸기)', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json(expiredSession))
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [['x:AccountPassword/get', { list: [{}] }, 'g']],
|
||||
}),
|
||||
);
|
||||
|
||||
await client.callAccount(ARA, (accountId) => [
|
||||
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'g'],
|
||||
]);
|
||||
|
||||
expect(JSON.parse(requestAt(1).init.body as string)).toEqual({
|
||||
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
methodCalls: [
|
||||
[
|
||||
'x:AccountPassword/get',
|
||||
{ accountId: 'x1', ids: ['singleton'] },
|
||||
'g',
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('만료의 모양 (isPasswordExpiredPermissions) — 바꾸기가 있고 다섯 밖의 권한이 없을 때만', () => {
|
||||
expect(isPasswordExpiredPermissions(EXPIRED_PERMISSIONS)).toBe(true);
|
||||
expect(
|
||||
isPasswordExpiredPermissions([
|
||||
'authenticate',
|
||||
'sysAccountPasswordUpdate',
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPasswordExpiredPermissions([...EXPIRED_PERMISSIONS, 'jmapEmailGet']),
|
||||
).toBe(false);
|
||||
expect(isPasswordExpiredPermissions(['authenticate'])).toBe(false);
|
||||
expect(isPasswordExpiredPermissions([])).toBe(false);
|
||||
expect(isPasswordExpiredPermissions(undefined)).toBe(false);
|
||||
expect(isPasswordExpiredPermissions('sysAccountPasswordUpdate')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('대리 접근 세션 (getSession)', () => {
|
||||
it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
@@ -830,13 +943,20 @@ describe('JmapClient', () => {
|
||||
),
|
||||
);
|
||||
|
||||
// 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제
|
||||
fetchMock.mockResolvedValueOnce(json({ permissions: ['authenticate'] }));
|
||||
|
||||
const code = await runWithRequestContext({ requestId: 'trace-403' }, () =>
|
||||
codeOf(client.getSession(ARA)),
|
||||
);
|
||||
|
||||
expect(code).toBe('MAIL_001');
|
||||
// 게이트웨이 확인을 하지 않는다 — 403 은 인증이 이미 된 상태다
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
// 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(requestAt(1).url).toBe('http://mail:8080/api/account');
|
||||
expect(decodeBasic(requestAt(1).authorization)).toBe(
|
||||
'mail-gateway@arasystem.test:gateway-secret',
|
||||
);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^\[MAIL_001\]\[trace-403\] .*대리 접근 권한이 없습니다/,
|
||||
@@ -844,6 +964,61 @@ describe('JmapClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('대리 접근 403 인데 게이트웨이는 대리 접근할 수 있으면 그 계정 문제(잠김) — AccountLoginDisabledException(MAIL_001) · warn, 확인은 1분 기억', async () => {
|
||||
const errorLog = jest
|
||||
.spyOn(
|
||||
Reflect.get(client, 'logger') as { error: (message: string) => void },
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
const warnLog = jest
|
||||
.spyOn(
|
||||
Reflect.get(client, 'logger') as { warn: (message: string) => void },
|
||||
'warn',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({ title: 'Forbidden', status: 403 }, 403))
|
||||
.mockResolvedValueOnce(
|
||||
json({ permissions: ['authenticate', 'impersonate'] }),
|
||||
);
|
||||
|
||||
const failure = await client
|
||||
.getSession(ARA, { fresh: true })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(AccountLoginDisabledException);
|
||||
expect((failure as AccountLoginDisabledException).code).toBe('MAIL_001');
|
||||
expect((failure as AccountLoginDisabledException).account).toBe(ARA);
|
||||
expect(errorLog).not.toHaveBeenCalled();
|
||||
expect(warnLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining('그 계정에 로그인 권한이 없습니다(잠김)'),
|
||||
);
|
||||
|
||||
// 1분 안의 다음 403 은 게이트웨이를 다시 묻지 않는다
|
||||
fetchMock.mockResolvedValueOnce(json({ title: 'Forbidden' }, 403));
|
||||
await expect(
|
||||
client.getSession(ARA, { fresh: true }),
|
||||
).rejects.toBeInstanceOf(AccountLoginDisabledException);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('게이트웨이 권한을 읽지 못하면 설정 문제 쪽으로 — 지금까지와 같은 MAIL_001 + error', async () => {
|
||||
jest
|
||||
.spyOn(
|
||||
Reflect.get(client, 'logger') as { error: (message: string) => void },
|
||||
'error',
|
||||
)
|
||||
.mockImplementation(() => undefined);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({ title: 'Forbidden' }, 403))
|
||||
.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
const failure = await client
|
||||
.getSession(ARA, { fresh: true })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).not.toBeInstanceOf(AccountLoginDisabledException);
|
||||
expect((failure as BusinessException).code).toBe('MAIL_001');
|
||||
});
|
||||
|
||||
it('막는 계정(메일 관리자)은 엔진에 대리 접근을 요청하지 않고 AUTH_001 (대소문자 무관)', async () => {
|
||||
for (const user of ['admin@arasystem.test', ' Admin@AraSystem.TEST']) {
|
||||
expect(await codeOf(client.getSession(user, { fresh: true }))).toBe(
|
||||
@@ -994,6 +1169,54 @@ describe('JmapClient', () => {
|
||||
fetchMock.mockResolvedValueOnce(json(sessionBody));
|
||||
});
|
||||
|
||||
it('메일 메서드가 forbidden 이고 그 계정이 비밀번호 만료 모양이면 PasswordExpiredException(401 AUTH_001) — 기억한 세션도 버린다 (실사용 시험)', async () => {
|
||||
// 세션은 기억에서 — 그사이 운영 도구가 비밀번호를 재설정했다(엔진이 권한을 줄였다)
|
||||
await client.getSession(ARA);
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
methodResponses: [
|
||||
['error', { type: 'forbidden' }, 'q'],
|
||||
['error', { type: 'invalidResultReference' }, 'g'],
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
permissions: [
|
||||
'authenticate',
|
||||
'authenticateWithAlias',
|
||||
'sysAccountPasswordGet',
|
||||
'sysAccountPasswordUpdate',
|
||||
'emailReceive',
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const failure = await client
|
||||
.call(ARA, (accountId) => [
|
||||
['EmailSubmission/query', { accountId }, 'q'],
|
||||
])
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(PasswordExpiredException);
|
||||
expect((failure as PasswordExpiredException).code).toBe('AUTH_001');
|
||||
expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('메일 메서드가 forbidden 이어도 만료 모양이 아니면 응답을 그대로 돌려준다 — 해석은 부른 쪽(AUTH_003 등)', async () => {
|
||||
const responses = [['error', { type: 'forbidden' }, 'm']];
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(json({ methodResponses: responses }))
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client.call(ARA, (accountId) => [['Mailbox/get', { accountId }, 'm']]),
|
||||
).resolves.toEqual(responses);
|
||||
});
|
||||
|
||||
it('세션의 accountId 로 호출을 만들고 내부 주소의 API 경로로 보낸다', async () => {
|
||||
const responses = [['Mailbox/get', { list: [] }, 'm']];
|
||||
fetchMock.mockResolvedValueOnce(json({ methodResponses: responses }));
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { ReadableStream as NodeReadableStream } from 'stream/web';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { ErrorCode } from '@/common/errors/error-codes';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
PasswordExpiredException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import {
|
||||
MAIL_SETTINGS,
|
||||
isMailEngineConfigured,
|
||||
@@ -19,6 +23,7 @@ import type {
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { fallbackDisplayName } from './jmap.mapper';
|
||||
import type {
|
||||
JmapInvocation,
|
||||
JmapProblem,
|
||||
@@ -142,6 +147,64 @@ export function classifyPasswordChange(
|
||||
return { kind: 'unknown', detail: String(name) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진의 계정 정보(권한 목록) — Stalwart 관리 API. 본인 Basic · 대리 접근 모두 그 계정의 지금 권한을 준다
|
||||
* (`{ permissions: [...], edition, locale }` — 2026-11-02 개발 엔진(0.16.27) 확인)
|
||||
*/
|
||||
const ACCOUNT_INFO_PATH = '/api/account';
|
||||
|
||||
/**
|
||||
* 비밀번호 자격이 만료된 계정의 권한 — 엔진이 이 다섯으로 줄인다 (0.16.27 소스 `access_token.rs` 의 assert_is_valid · 2026-11-02 개발 엔진 확인:
|
||||
* 본인 Basic 은 200, 대리 접근 세션도 열리지만 둘 다 메일 기능이 없다. 본인이 비밀번호를 바꾸면 엔진이 만료를 스스로 푼다)
|
||||
*/
|
||||
const EXPIRED_PASSWORD_PERMISSIONS: ReadonlySet<string> = new Set([
|
||||
'authenticate',
|
||||
'authenticateWithAlias',
|
||||
'sysAccountPasswordGet',
|
||||
'sysAccountPasswordUpdate',
|
||||
'emailReceive',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 권한 목록이 "비밀번호 만료"의 모양인가 (순수 함수) — 비밀번호 바꾸기가 있고 다섯 밖의 권한이 하나도 없다.
|
||||
* 역할 설정 실수로 메일 권한만 빠진 계정(바꾸기도 없음)은 만료가 아니다 — 비밀번호를 바꿔도 풀리지 않으니 바꾸기 단계로 보내지 않는다
|
||||
*/
|
||||
export function isPasswordExpiredPermissions(permissions: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(permissions) &&
|
||||
permissions.includes('sysAccountPasswordUpdate') &&
|
||||
permissions.every(
|
||||
(permission) =>
|
||||
typeof permission === 'string' &&
|
||||
EXPIRED_PASSWORD_PERMISSIONS.has(permission),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 엔진 계정 정보의 응답 — 권한 목록만 읽는다 */
|
||||
interface AccountInfoReply {
|
||||
readonly permissions?: unknown;
|
||||
}
|
||||
|
||||
/** 대리 접근 권한의 이름 — 게이트웨이 자신의 권한(/api/account)에 있어야 한다 (2026-11-02 개발 엔진: authenticate · impersonate 둘) */
|
||||
const IMPERSONATE_PERMISSION = 'impersonate';
|
||||
/** 게이트웨이가 대리 접근할 수 있다고 확인한 것을 기억하는 시간 — 잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게 */
|
||||
const GATEWAY_IMPERSONATION_MEMORY_MS = 60_000;
|
||||
|
||||
/** 메서드 오류 forbidden 인가 — 메일 메서드가 막혔다 */
|
||||
function isForbiddenInvocation(invocation: JmapInvocation): boolean {
|
||||
return (
|
||||
invocation[0] === 'error' &&
|
||||
(invocation[1] as JmapProblem | undefined)?.type === 'forbidden'
|
||||
);
|
||||
}
|
||||
|
||||
/** 계정 관리 기능만 쓰는 세션 — 비밀번호가 만료돼 메일 기능이 없는 계정도 열린다 (2단계 인증 · 앱 비밀번호 · 2단계 직원의 비밀번호 바꾸기) */
|
||||
interface ManagementSession {
|
||||
readonly apiPath: string;
|
||||
readonly managementAccountId: string;
|
||||
}
|
||||
|
||||
/** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지. 오래 걸리는 처리는 남은 시간이 이보다 적으면 그만큼만 준다 (jmap-mail.provider.ts) */
|
||||
export const JMAP_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const REQUEST_TIMEOUT_MS = JMAP_REQUEST_TIMEOUT_MS;
|
||||
@@ -235,11 +298,6 @@ 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)으로 쓰지 않는다
|
||||
@@ -391,6 +449,8 @@ export class JmapClient {
|
||||
private readonly logger = new Logger(JmapClient.name);
|
||||
/** 관리 권한 확인 응답을 해석하지 못했다는 error 로그를 이미 남겼는가 (엔진 API 가 바뀐 경우 — 한 번만 알린다) */
|
||||
private managementProbeUnclearReported = false;
|
||||
/** 게이트웨이가 대리 접근할 수 있다고 확인한 때부터 이 시각까지 다시 묻지 않는다 (GATEWAY_IMPERSONATION_MEMORY_MS) */
|
||||
private gatewayImpersonationKnownUntil = 0;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly settings: MailSettings,
|
||||
@@ -725,20 +785,15 @@ export class JmapClient {
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const session = await this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: this.impersonation(user) } },
|
||||
const resource = await this.fetchSessionResource(
|
||||
user,
|
||||
options.timeoutMs ?? REQUEST_TIMEOUT_MS,
|
||||
'session',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
await this.assertOk(response, 'session');
|
||||
return this.toSession(
|
||||
await readJson<JmapSessionResource>(response, 'session'),
|
||||
user,
|
||||
);
|
||||
},
|
||||
);
|
||||
// 메일 기능이 없다 — 비밀번호가 만료된 계정이면 그렇게 알린다(13단계), 아니면 아래 toSession 이 MAIL_001
|
||||
if (!resource.primaryAccounts?.[MAIL_CAPABILITY]) {
|
||||
await this.throwIfPasswordExpired(user, resource);
|
||||
}
|
||||
const session = this.toSession(resource, user);
|
||||
|
||||
// 엔진이 알려 준 정식 주소로 한 번 더 — 로컬 이름(admin)이나 별칭으로 들어와도 막는 계정이면 캐시하지 않는다
|
||||
this.assertNotBlocked(session.username);
|
||||
@@ -746,6 +801,76 @@ export class JmapClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
/** 대리 접근으로 엔진 세션 자원을 읽는다 — 401 · 403 은 다른 대리 접근 호출과 같은 분류 */
|
||||
private async fetchSessionResource(
|
||||
user: string,
|
||||
timeoutMs: number,
|
||||
): Promise<JmapSessionResource> {
|
||||
return this.exchange(
|
||||
SESSION_PATH,
|
||||
{ headers: { authorization: this.impersonation(user) } },
|
||||
timeoutMs,
|
||||
'session',
|
||||
async (response) => {
|
||||
await this.assertImpersonationAccepted(response, user);
|
||||
await this.assertOk(response, 'session');
|
||||
return readJson<JmapSessionResource>(response, 'session');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 계정의 비밀번호가 만료됐으면 PasswordExpiredException 으로 던진다(기억한 세션도 버린다) — 아니거나 알 수 없으면 아무것도 하지 않는다.
|
||||
* 세션은 열렸는데 메일 메서드가 forbidden 일 때 부른다(13단계): 엔진은 비밀번호를 바꾼 직후 잠깐(0.5초 남짓) 옛 세션 자원을 주고,
|
||||
* 이 서버는 세션을 5분 기억한다 — 그 사이에 만료된 계정이면 프로필 읽기가 forbidden 으로 끝난다
|
||||
*/
|
||||
async throwIfPasswordExpiredFor(user: string): Promise<void> {
|
||||
if (await this.isPasswordExpired(user)) {
|
||||
await this.cache.del(sessionCacheKey(user));
|
||||
const account = user.trim().toLowerCase();
|
||||
throw new PasswordExpiredException(account, fallbackDisplayName(account));
|
||||
}
|
||||
}
|
||||
|
||||
/** 대리 접근으로 그 계정의 권한을 읽어 "비밀번호 만료"의 모양인가 — 읽지 못하면 false */
|
||||
private async isPasswordExpired(user: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.exchange(
|
||||
ACCOUNT_INFO_PATH,
|
||||
{ headers: { authorization: this.impersonation(user) } },
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'account-info',
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
await discard(response);
|
||||
return false;
|
||||
}
|
||||
const reply = await readJson<AccountInfoReply>(
|
||||
response,
|
||||
'account-info',
|
||||
);
|
||||
return isPasswordExpiredPermissions(reply.permissions);
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 기능이 없는 세션 — 그 계정의 권한이 "비밀번호 만료"의 다섯뿐이면 PasswordExpiredException(정식 주소)으로 던진다 (13단계 — 계약 6절).
|
||||
* 권한을 읽지 못하거나 다른 모양이면 아무것도 하지 않는다(부르는 쪽이 지금처럼 MAIL_001 — 세션이 모자람)
|
||||
*/
|
||||
private async throwIfPasswordExpired(
|
||||
user: string,
|
||||
resource: JmapSessionResource,
|
||||
): Promise<void> {
|
||||
if (!(await this.isPasswordExpired(user))) return;
|
||||
const account = resource.username?.trim().toLowerCase() || user;
|
||||
this.assertNotBlocked(account);
|
||||
throw new PasswordExpiredException(account, fallbackDisplayName(account));
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)을 대리 접근으로 부른다 (12단계 — 2단계 인증 · 앱 비밀번호).
|
||||
* 계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`(메일 계정 id 와 따로 본다), `using` 은 core · 그 기능뿐이다.
|
||||
@@ -849,20 +974,47 @@ export class JmapClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** 계정 관리 기능의 계정 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 });
|
||||
/**
|
||||
* 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 기억한 메일 세션에 그 값이 있으면 그것을, 없으면 세션 자원을 다시 읽는다.
|
||||
* 메일 기능이 없는 계정(비밀번호 만료 — 13단계)도 연다: 만료된 동안에도 2단계 인증 상태 읽기 · 2단계 직원의 비밀번호 바꾸기는 엔진이 받는다
|
||||
* (2026-11-02 확인). 엔진이 그 기능을 알려 주지 않으면 MAIL_001
|
||||
*/
|
||||
private async accountSession(user: string): Promise<ManagementSession> {
|
||||
this.assertNotBlocked(user);
|
||||
const cached = await this.cache.get<JmapSession>(sessionCacheKey(user));
|
||||
if (cached?.managementAccountId) {
|
||||
return {
|
||||
apiPath: cached.apiPath,
|
||||
managementAccountId: cached.managementAccountId,
|
||||
};
|
||||
}
|
||||
const { managementAccountId } = session;
|
||||
if (!managementAccountId) {
|
||||
|
||||
const resource = await this.fetchSessionResource(user, REQUEST_TIMEOUT_MS);
|
||||
this.assertNotBlocked(resource.username?.trim().toLowerCase() || user);
|
||||
const managementAccountId =
|
||||
resource.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY];
|
||||
if (!managementAccountId || !resource.apiUrl) {
|
||||
throw engineFailure({
|
||||
reason: 'account-capability-missing',
|
||||
call: 'account-session',
|
||||
});
|
||||
}
|
||||
return { ...session, managementAccountId };
|
||||
// 메일 기능도 있으면 메일 세션으로 함께 기억한다 (다음 메일 호출이 다시 묻지 않게)
|
||||
if (resource.primaryAccounts?.[MAIL_CAPABILITY]) {
|
||||
try {
|
||||
await this.cache.set(
|
||||
sessionCacheKey(user),
|
||||
this.toSession(resource, user),
|
||||
SESSION_CACHE_TTL_MS,
|
||||
);
|
||||
} catch {
|
||||
// 메일 세션이 모자라도 계정 관리 호출은 이어 간다
|
||||
}
|
||||
}
|
||||
return {
|
||||
apiPath: toRelativeReference(resource.apiUrl),
|
||||
managementAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -962,7 +1114,7 @@ export class JmapClient {
|
||||
methodCalls: build(session.accountId),
|
||||
});
|
||||
|
||||
return this.exchange(
|
||||
const responses = await this.exchange(
|
||||
session.apiPath,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1004,6 +1156,12 @@ export class JmapClient {
|
||||
return parsed.methodResponses;
|
||||
},
|
||||
);
|
||||
// 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면(엔진이 권한을 비밀번호 바꾸기만으로 줄였다 · 세션은 5분 기억한다) 그렇게 알린다 (13단계).
|
||||
// 아니면 그대로 돌려준다 — 해석은 부른 쪽이 한다(AUTH_003 등)
|
||||
if (responses.some(isForbiddenInvocation)) {
|
||||
await this.throwIfPasswordExpiredFor(user);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1460,9 +1618,11 @@ export class JmapClient {
|
||||
* 대리 접근 거부를 해석한다 (Stalwart v0.16 개발 엔진에서 확인):
|
||||
* - **401**: 대상 계정이 없거나(다른 도메인 주소 포함) 게이트웨이 비밀번호가 틀렸다 — 응답이 같다.
|
||||
* 그래서 게이트웨이로 직접 로그인해 구분한다: 게이트웨이도 거부되면 설정 문제(MAIL_001), 아니면 계정 없음(AUTH_001)
|
||||
* - **403** "You do not have enough permissions": 인증은 됐지만 **대리 접근 권한이 없다** — 게이트웨이 설정 문제다.
|
||||
* 계정 없음으로 보면 설정 실수 한 번에 전 직원의 세션이 폐기된다(리프레시가 AUTH_001 → 세션 폐기).
|
||||
* 그래서 MAIL_001(503) + error 로그로 답하고 세션은 건드리지 않는다.
|
||||
* - **403** "You do not have enough permissions": 인증은 됐지만 대리 접근이 막혔다 — 게이트웨이에 대리 접근 권한이 없거나(설정 문제)
|
||||
* **대상 계정에 로그인 권한(authenticate)이 없다**(잠김 — 13단계 운영 도구 · 엔진 관리 화면). 게이트웨이 자신의 권한(/api/account)에
|
||||
* impersonate 가 있으면 그 계정 문제다 → AccountLoginDisabledException(여전히 MAIL_001 — 로그인만 AUTH_004 로 바꾼다, warn).
|
||||
* 없거나 읽지 못하면 설정 문제로 본다 → MAIL_001(503) + error. 어느 쪽도 계정 없음(AUTH_001)으로 보지 않는다 —
|
||||
* 설정 실수 한 번에 전 직원의 세션이 폐기되지 않게(리프레시가 AUTH_001 → 세션 폐기). 세션은 건드리지 않는다.
|
||||
*/
|
||||
private async assertImpersonationAccepted(
|
||||
response: Response,
|
||||
@@ -1479,6 +1639,13 @@ export class JmapClient {
|
||||
await this.cache.del(sessionCacheKey(user));
|
||||
|
||||
if (response.status === HTTP_FORBIDDEN) {
|
||||
if (await this.gatewayCanImpersonate()) {
|
||||
// 게이트웨이는 대리 접근할 수 있다 — 이 계정이 로그인할 수 없다(잠김). 서버 장애가 아니다
|
||||
this.logger.warn(
|
||||
`[MAIL_001]${traceTag()} 대리 접근이 막혔습니다(403) — 게이트웨이는 대리 접근할 수 있으니 그 계정에 로그인 권한이 없습니다(잠김): user=${maskEmail(user)}`,
|
||||
);
|
||||
throw new AccountLoginDisabledException(user.trim().toLowerCase());
|
||||
}
|
||||
this.logger.error(
|
||||
`[MAIL_001]${traceTag()} 메일 게이트웨이 계정에 대리 접근 권한이 없습니다(403) — 계정 문제가 아니라 설정 문제로 보고 세션을 유지합니다. 게이트웨이 계정의 대리 접근(impersonate) 권한과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`,
|
||||
);
|
||||
@@ -1504,6 +1671,48 @@ export class JmapClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 게이트웨이가 대리 접근할 수 있는가 — 자신의 권한(/api/account)에 impersonate 가 있는가. 읽지 못하면 false(설정 문제 쪽 — 지금까지와 같은 503).
|
||||
* 있으면 1분 기억한다(잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게)
|
||||
*/
|
||||
private async gatewayCanImpersonate(): Promise<boolean> {
|
||||
if (this.gatewayImpersonationKnownUntil > Date.now()) return true;
|
||||
const { gatewayUser, gatewayPassword } = this.requireConfigured();
|
||||
try {
|
||||
const allowed = await this.exchange(
|
||||
ACCOUNT_INFO_PATH,
|
||||
{
|
||||
headers: {
|
||||
authorization: basicAuthorization(gatewayUser, gatewayPassword),
|
||||
},
|
||||
},
|
||||
REQUEST_TIMEOUT_MS,
|
||||
'gateway-permissions',
|
||||
async (response) => {
|
||||
if (!response.ok) {
|
||||
await discard(response);
|
||||
return false;
|
||||
}
|
||||
const reply = await readJson<AccountInfoReply>(
|
||||
response,
|
||||
'gateway-permissions',
|
||||
);
|
||||
return (
|
||||
Array.isArray(reply.permissions) &&
|
||||
reply.permissions.includes(IMPERSONATE_PERMISSION)
|
||||
);
|
||||
},
|
||||
);
|
||||
if (allowed) {
|
||||
this.gatewayImpersonationKnownUntil =
|
||||
Date.now() + GATEWAY_IMPERSONATION_MEMORY_MS;
|
||||
}
|
||||
return allowed;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async gatewayAccepted(): Promise<boolean> {
|
||||
const { gatewayUser, gatewayPassword } = this.requireConfigured();
|
||||
return this.exchange(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -36,7 +37,7 @@ import { MailService } from './mail.service';
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('mail-attachments')
|
||||
export class MailAttachmentController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -43,7 +44,7 @@ const BLOCK_NOTE =
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다',
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
@@ -61,6 +64,7 @@ export function eventStreamSink(res: Response): MailEventSink {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors()
|
||||
@Controller('mail-events')
|
||||
export class MailEventsController {
|
||||
constructor(private readonly events: MailEventsService) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -46,7 +47,7 @@ const APPLY_NOTE =
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(MAIL_FILTERS_ROUTE)
|
||||
export class MailFilterController {
|
||||
@@ -106,7 +107,7 @@ export class MailFilterController {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(MAIL_FILTER_RUNS_ROUTE)
|
||||
export class MailFilterRunController {
|
||||
constructor(private readonly mail: MailService) {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
FileScanService,
|
||||
} from '@/modules/file-storage/file-scan.service';
|
||||
import type { FileStorageService } from '@/modules/file-storage/file-storage.service';
|
||||
import type { SystemMailService } from '@/modules/system-mail/system-mail.service';
|
||||
import type {
|
||||
FileUseProblem,
|
||||
StoredFileView,
|
||||
@@ -161,6 +162,9 @@ function setup() {
|
||||
}),
|
||||
};
|
||||
const events = { notify: jest.fn() };
|
||||
const systemMail = {
|
||||
notifyDangerousFile: jest.fn().mockResolvedValue('notice-1'),
|
||||
};
|
||||
const provider = {
|
||||
getMessage: jest.fn(),
|
||||
getMessageSubjects: jest.fn().mockResolvedValue(new Map()),
|
||||
@@ -171,6 +175,7 @@ function setup() {
|
||||
scans as unknown as FileScanService,
|
||||
events as unknown as MailEventsService,
|
||||
provider as unknown as MailProvider,
|
||||
systemMail as unknown as SystemMailService,
|
||||
);
|
||||
service.onModuleInit();
|
||||
return {
|
||||
@@ -179,10 +184,17 @@ function setup() {
|
||||
sharing,
|
||||
events,
|
||||
provider,
|
||||
systemMail,
|
||||
verdict: (event: Parameters<FileScanListener>[0]) => listener?.(event),
|
||||
};
|
||||
}
|
||||
|
||||
/** 판정 소식의 크기 · 올린 때 (13단계에 더한 칸) */
|
||||
const UPLOADED = {
|
||||
size: 50_538_291,
|
||||
uploadedAt: new Date('2026-10-30T06:58:00Z'),
|
||||
};
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
@@ -579,12 +591,14 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
|
||||
fileId: FILE_A,
|
||||
name: '견적서.zip',
|
||||
scanStatus: 'infected',
|
||||
...UPLOADED,
|
||||
});
|
||||
verdict({
|
||||
ownerEmail: ARA.email,
|
||||
fileId: FILE_B,
|
||||
name: '도면.dwg',
|
||||
scanStatus: 'clean',
|
||||
...UPLOADED,
|
||||
});
|
||||
expect(events.notify).toHaveBeenCalledTimes(1);
|
||||
expect(events.notify).toHaveBeenCalledWith(ARA.email, {
|
||||
@@ -593,4 +607,28 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () =
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('위험 판정이면 받은편지함 알림 메일도 넣는다 — 파일 이름 · 크기 · 올린 때 (13단계, 깨끗함은 넣지 않는다)', () => {
|
||||
const { systemMail, verdict } = setup();
|
||||
verdict({
|
||||
ownerEmail: ARA.email,
|
||||
fileId: FILE_A,
|
||||
name: '견적서.zip',
|
||||
scanStatus: 'infected',
|
||||
...UPLOADED,
|
||||
});
|
||||
verdict({
|
||||
ownerEmail: ARA.email,
|
||||
fileId: FILE_B,
|
||||
name: '도면.dwg',
|
||||
scanStatus: 'clean',
|
||||
...UPLOADED,
|
||||
});
|
||||
expect(systemMail.notifyDangerousFile).toHaveBeenCalledTimes(1);
|
||||
expect(systemMail.notifyDangerousFile).toHaveBeenCalledWith(ARA.email, {
|
||||
name: '견적서.zip',
|
||||
size: UPLOADED.size,
|
||||
uploadedAt: UPLOADED.uploadedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
MissingLargeFile,
|
||||
StoredFileView,
|
||||
} from '@/modules/file-storage/file-storage.types';
|
||||
import { SystemMailService } from '@/modules/system-mail/system-mail.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { kstEndOfDayAfter } from '@/shared/utils/kst-date';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
@@ -131,7 +132,7 @@ function unusableFiles(
|
||||
/**
|
||||
* 메일과 대용량 첨부(11단계 — 계약 4절 "발송 · 임시 저장과 함께" · "우리 웹메일에서 읽기" · "보낸 사람 관리")를 잇는다.
|
||||
* 파일은 파일 모듈, 링크는 공유 모듈이 맡고 — 메일 → 공유 → 저장 한 방향 — 이 서비스는 메일 쪽 규칙(덩이 · 자리 · 머리글 · 보낸 사람)만 둔다.
|
||||
* 위험 판정 소식을 실시간 알림(`large-file`)으로 보낸다.
|
||||
* 위험 판정 소식을 실시간 알림(`large-file`)으로 보내고, 받은편지함 알림 메일도 넣는다(13단계 — 그때 웹메일을 열어 두지 않은 직원도 알게).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -144,6 +145,8 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly scans: FileScanService,
|
||||
private readonly events: MailEventsService,
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
/** 13단계 — 위험 판정의 받은편지함 알림 메일 */
|
||||
private readonly systemMail: SystemMailService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -158,6 +161,12 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy {
|
||||
scanStatus: 'infected',
|
||||
},
|
||||
});
|
||||
// 받은편지함 알림 메일 (계약 6절 — 파일마다 한 번: 검사 차례가 위험 판정을 처음 적을 때만 이 소식을 보낸다). 넣지 못해도 던지지 않는다
|
||||
void this.systemMail.notifyDangerousFile(event.ownerEmail, {
|
||||
name: event.name,
|
||||
size: event.size,
|
||||
uploadedAt: event.uploadedAt,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
|
||||
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
|
||||
import { SystemMailModule } from '@/modules/system-mail/system-mail.module';
|
||||
import { isAllowedAttachment } from './attachment.policy';
|
||||
import { DraftController } from './draft.controller';
|
||||
import { MailImageController } from './images/mail-image.controller';
|
||||
@@ -124,6 +125,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
FileSharingModule,
|
||||
// 끊은 세션 기억 (12단계) — 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝낸다. 이 모듈은 메일을 가져오지 않는다
|
||||
AuthSessionModule,
|
||||
// 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다
|
||||
SystemMailModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
|
||||
@@ -64,6 +64,34 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
*/
|
||||
export class SendNotStartedException extends BusinessException {}
|
||||
|
||||
/**
|
||||
* 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다
|
||||
* (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다.
|
||||
* 인증 모듈은 로그인 · 갱신에서 이것을 받아 세션에 passwordChange 제한을 붙인다 — 형식으로 가른다(context 는 로그 전용, 갱신은 세션을 끊지 않는다).
|
||||
* 메일 API 의 응답으로 나가면 **401 AUTH_001** 이다 — 화면은 곧 갱신하고, 세션이 끊겼으면 세션 만료, 아직이면 갱신이 만료를 알아채 새 비밀번호 단계로 간다
|
||||
* (운영 도구의 재설정 직후 끊은 세션이 막히기 전 15초 동안 빈 칸 · 503 이 보이지 않게).
|
||||
* `account` 는 엔진이 알려 준 정식 주소, `displayName` 은 메일 기능 없이 쓸 표시 이름(주소의 @ 앞 — 표시 이름은 메일 기능으로만 읽는다)
|
||||
*/
|
||||
export class PasswordExpiredException extends BusinessException {
|
||||
constructor(
|
||||
readonly account: string,
|
||||
readonly displayName: string,
|
||||
) {
|
||||
super('AUTH_001', { context: { reason: 'password-expired' } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 그 계정의 대리 접근을 막았다(403)인데 게이트웨이는 대리 접근할 수 있다 — 그 계정에 로그인 권한(authenticate)이 없다(잠김) (13단계).
|
||||
* 게이트웨이 설정 문제와 가르는 것은 어댑터다(게이트웨이 자신의 권한에 impersonate 가 있는가). 응답으로 나가면 지금처럼 503 MAIL_001 이고
|
||||
* 로그인만 이것을 AUTH_004 로 바꾼다 — 2단계를 켠 잠긴 계정이 맞는 비밀번호에만 503 을 받아 비밀번호가 맞는지 바깥에서 알 수 있던 길(실사용 시험)
|
||||
*/
|
||||
export class AccountLoginDisabledException extends BusinessException {
|
||||
constructor(readonly account: string) {
|
||||
super('MAIL_001', { context: { reason: 'account-login-disabled' } });
|
||||
}
|
||||
}
|
||||
|
||||
export interface MailProvider {
|
||||
/**
|
||||
* 직원 자격 증명을 메일 엔진에 확인한다 (CredentialCheck — 맞음 · 틀림 · 2단계 인증 코드 필요). 비밀번호는 저장하지 않는다.
|
||||
|
||||
@@ -137,6 +137,21 @@ export function buildMailSettings(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 엔진 관리자 주소 — `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (소문자, 도메인이 없으면 null).
|
||||
* 운영 도구(13단계)가 관리 API 에 들어갈 때 쓴다 — 막는 목록(blockedAccounts)에 넣는 것과 같은 값이다
|
||||
*/
|
||||
export function buildMailAdminAccount(
|
||||
read: (key: string) => string | undefined,
|
||||
): string | null {
|
||||
const domain = readTrimmed(read, 'MAIL_DOMAIN')?.toLowerCase() ?? null;
|
||||
if (domain === null) return null;
|
||||
const local = (
|
||||
readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART
|
||||
).toLowerCase();
|
||||
return `${local}@${domain}`;
|
||||
}
|
||||
|
||||
/** 콤마로 나열한 주소 목록 → 소문자 주소 (형식 검사는 env 검증이 한다) */
|
||||
export function parseAccountList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -63,8 +64,7 @@ const BULK_NO_PROGRESS =
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@@ -200,7 +200,7 @@ export class MailboxController {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller('mail-vacation')
|
||||
export class MailVacationController {
|
||||
@@ -252,7 +252,7 @@ export class MailVacationController {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller('mail-quota')
|
||||
export class MailQuotaController {
|
||||
@@ -276,7 +276,7 @@ export class MailQuotaController {
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -27,8 +28,7 @@ import { MailService } from './mail.service';
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller('message-deletions')
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -83,8 +84,7 @@ const LARGE_FILE_THROTTLE_LIMIT = 300;
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
@ApiSessionErrors(
|
||||
'로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다',
|
||||
)
|
||||
@Controller(MESSAGES_ROUTE)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -18,7 +19,7 @@ import { RecipientSuggestionService } from './recipient-suggestions.service';
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller('recipient-suggestions')
|
||||
export class RecipientSuggestionController {
|
||||
constructor(private readonly suggestions: RecipientSuggestionService) {}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -30,7 +31,7 @@ import { ScheduledMessagesService } from './scheduled-messages.service';
|
||||
@ApiTags('Mail')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@Controller(SCHEDULED_MESSAGES_ROUTE)
|
||||
export class ScheduledMessageController {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ export const SHARE_LINKS_ROUTE = 'share-links';
|
||||
@ApiTags('Files')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)')
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)')
|
||||
@Controller(SHARE_LINKS_ROUTE)
|
||||
export class ShareLinkController {
|
||||
constructor(private readonly largeFiles: MailLargeFilesService) {}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { SystemMailService } from '@/modules/system-mail/system-mail.service';
|
||||
import { SystemSettingsService } from '@/modules/system-settings/system-settings.service';
|
||||
import { twoFactorRequirementFor } from '@/modules/system-settings/system-settings.types';
|
||||
import { OpsAuditService } from './ops-audit.service';
|
||||
import { OpsDataService, totalRows } from './ops-data.service';
|
||||
import {
|
||||
OPS_ENGINE,
|
||||
OpsEngineError,
|
||||
type EngineEmployee,
|
||||
type OpsEngine,
|
||||
} from './ops-engine.port';
|
||||
import type { OpsIo } from './ops-io';
|
||||
import {
|
||||
askAddress,
|
||||
askEmployee,
|
||||
confirmPlan,
|
||||
failureLine,
|
||||
showTempPassword,
|
||||
step,
|
||||
} from './ops-prompts';
|
||||
import {
|
||||
bytesText,
|
||||
failureDetail,
|
||||
isUnknownOutcome,
|
||||
toDisplayName,
|
||||
toQuotaBytes,
|
||||
} from './ops-text';
|
||||
import { generateTempPassword } from './temp-password';
|
||||
|
||||
/** 임시 비밀번호를 엔진이 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */
|
||||
const WEAK_PASSWORD_RETRIES = 2;
|
||||
|
||||
/**
|
||||
* 운영 도구 — 계정 명령 (13단계 — 계약 6절 "운영 도구"): 입사 · 비밀번호 재설정 · 2단계 해제.
|
||||
* 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 비밀번호는 한 번만 보이고 어디에도 남기지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccountCommands {
|
||||
constructor(
|
||||
@Inject(OPS_ENGINE) private readonly engine: OpsEngine,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly data: OpsDataService,
|
||||
private readonly audit: OpsAuditService,
|
||||
private readonly systemMail: SystemMailService,
|
||||
private readonly settings: SystemSettingsService,
|
||||
) {}
|
||||
|
||||
private get domain(): string {
|
||||
return this.mailSettings.domain ?? '';
|
||||
}
|
||||
|
||||
/** 입사 — 엔진 계정(직원 역할) + 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + 메일함 용량 */
|
||||
async hire(io: OpsIo, operator: string): Promise<void> {
|
||||
const email = await askAddress(io, this.domain, '새 직원 주소');
|
||||
if (email === null) return;
|
||||
if (!this.engine.isEmployeeAddress(email)) {
|
||||
io.print(
|
||||
'그 주소는 직원 주소로 쓸 수 없습니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정의 이름입니다.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (await this.engine.addressInUse(email)) {
|
||||
io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return;
|
||||
}
|
||||
const name = toDisplayName(await io.ask('이름 (메일에 보이는 이름): '));
|
||||
if (name === null) {
|
||||
io.print('이름은 한 줄 1~100자로 넣어 주세요.');
|
||||
return;
|
||||
}
|
||||
const quota = toQuotaBytes(
|
||||
await io.ask('메일함 용량 GB (빈 값이면 제한 없음): '),
|
||||
);
|
||||
if (quota === 'invalid') {
|
||||
io.print('용량은 0 보다 큰 숫자(GB, 소수 한 자리까지)로 넣어 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 주소를 쓰던 옛 직원의 기록이 남았으면 먼저 지운다 — 새 사람에게 옛 로그인 기록 · 서명 · 2단계 기록 · 2단계 필수 예외가 넘어가지 않게
|
||||
const leftoverRows = totalRows(await this.data.countRowsOf(email));
|
||||
const leftoverExempt = await this.data.isTwoFactorExempt(email);
|
||||
const leftoverParts = [
|
||||
...(leftoverRows > 0 ? [`우리 DB 기록 ${leftoverRows}줄`] : []),
|
||||
...(leftoverExempt ? ['2단계 인증 필수의 예외 주소'] : []),
|
||||
];
|
||||
let purgeLeftover = false;
|
||||
if (leftoverParts.length > 0) {
|
||||
io.print(
|
||||
`이 주소에 같은 주소를 쓰던 사람의 것이 남아 있습니다 — ${leftoverParts.join(' · ')}`,
|
||||
);
|
||||
purgeLeftover = await io.confirm('입사 전에 지울까요? (권함)');
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`주소 ${email}`,
|
||||
`이름 ${name}`,
|
||||
`메일함 ${quota === null ? '제한 없음' : bytesText(quota)}`,
|
||||
'비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다',
|
||||
' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)',
|
||||
...(purgeLeftover
|
||||
? [`옛 기록 지우기 — ${leftoverParts.join(' · ')}`]
|
||||
: []),
|
||||
];
|
||||
if (!(await confirmPlan(io, '입사', lines))) return;
|
||||
|
||||
try {
|
||||
if (purgeLeftover) {
|
||||
await this.data.purgeAddress(email);
|
||||
step(io, true, `옛 기록을 지웠습니다 — ${leftoverParts.join(' · ')}`);
|
||||
}
|
||||
const localPart = email.slice(0, email.indexOf('@'));
|
||||
let password = '';
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
password = generateTempPassword();
|
||||
try {
|
||||
await this.engine.createEmployee({
|
||||
localPart,
|
||||
name,
|
||||
quotaBytes: quota,
|
||||
tempPassword: password,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
const weak =
|
||||
error instanceof OpsEngineError && error.kind === 'weak-password';
|
||||
if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error;
|
||||
}
|
||||
}
|
||||
step(io, true, '엔진 계정을 만들었습니다.');
|
||||
showTempPassword(io, email, password);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'hire',
|
||||
target: email,
|
||||
result: 'success',
|
||||
detail: `용량 ${quota === null ? '제한 없음' : bytesText(quota)}${purgeLeftover ? ` · 옛 기록 지움(${leftoverParts.join(' · ')})` : ''}`,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'hire',
|
||||
target: email,
|
||||
result: 'failure',
|
||||
detail: failureDetail(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 재설정 — 묻고 확인한 뒤 runPasswordReset */
|
||||
async resetPassword(io: OpsIo, operator: string): Promise<void> {
|
||||
const employee = await askEmployee(io, this.engine, this.domain);
|
||||
if (employee === null) return;
|
||||
if (employee.locked) {
|
||||
io.print(
|
||||
'잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요(잠금 풀기에서 비밀번호 재설정을 함께 할 수 있습니다).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!(await confirmPlan(io, '비밀번호 재설정', this.resetPlan(employee)))) {
|
||||
return;
|
||||
}
|
||||
await this.runPasswordReset(io, operator, employee);
|
||||
}
|
||||
|
||||
/** 비밀번호 재설정의 바뀔 것 — 이미 만료된 계정은 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다(엔진이 막는다) */
|
||||
resetPlan(employee: EngineEmployee): string[] {
|
||||
const expired = employee.passwordExpired;
|
||||
return [
|
||||
`대상 ${employee.name} <${employee.email}>`,
|
||||
expired
|
||||
? `1. 앱 비밀번호 — 건너뜀(비밀번호가 이미 만료된 계정이라 엔진이 막는다)${employee.appPasswords > 0 ? ` · ${employee.appPasswords}개가 남아 있다 — 직원이 새 비밀번호를 정한 뒤 다시 재설정` : ''}`
|
||||
: `1. 앱 비밀번호 ${employee.appPasswords}개 지우기 (메일 프로그램은 새 앱 비밀번호로 다시 연결)`,
|
||||
'2. 웹메일 로그인 모두 끊기',
|
||||
expired
|
||||
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
|
||||
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
|
||||
'4. 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다 (그사이 새로 로그인한 세션도 끊는다)',
|
||||
` 2단계 인증은 그대로 (${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} — 따로 「2단계 해제」)`,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정의 차례 (계약 6절 — 만료 뒤에는 대리 접근이 막히므로 대리 접근 일을 먼저 한다):
|
||||
* 1. 앱 비밀번호 지우기(대리 접근) — 지우지 못하면 아무것도 바꾸지 않고 멈춘다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게)
|
||||
* 2. 웹메일 세션 끊기(DB) — 알림 메일이 "끊었습니다"라고 말하기 전에 끝내 둔다. 끊지 못하면 멈춘다
|
||||
* 3. 받은편지함 알림(대리 접근) — 넣지 못해도 이어 간다
|
||||
* 4. 엔진에 임시 비밀번호 + 만료 — 실패하면 넣은 알림을 지운다(아직 만료 전이라 지울 수 있다)
|
||||
* 5. 세션 한 번 더 끊기 — 2와 4 사이에 옛 비밀번호로 새로 로그인한 세션
|
||||
* 이미 만료된 계정(지난 재설정 뒤 아직 바꾸지 않음)은 1 · 3 을 건너뛴다 — 엔진이 대리 접근을 막는다
|
||||
*/
|
||||
async runPasswordReset(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
employee: EngineEmployee,
|
||||
): Promise<void> {
|
||||
const notes: string[] = [];
|
||||
const fail = async (detail: string): Promise<void> => {
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'password-reset',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: [...notes, detail].join(' · '),
|
||||
});
|
||||
};
|
||||
|
||||
// 1. 앱 비밀번호
|
||||
if (employee.passwordExpired) {
|
||||
step(
|
||||
io,
|
||||
employee.appPasswords === 0,
|
||||
employee.appPasswords === 0
|
||||
? '앱 비밀번호 — 비밀번호가 이미 만료된 계정이라 건너뜁니다(만들 수 없었습니다).'
|
||||
: `앱 비밀번호 ${employee.appPasswords}개 — 비밀번호가 만료된 동안은 엔진이 지우기를 막습니다. 직원이 새 비밀번호를 정한 뒤 다시 재설정하세요.`,
|
||||
);
|
||||
notes.push('앱 비밀번호 건너뜀(이미 만료)');
|
||||
} else {
|
||||
try {
|
||||
const removed = await this.engine.deleteAppPasswords(employee.email);
|
||||
step(io, true, `앱 비밀번호 ${removed}개를 지웠습니다.`);
|
||||
notes.push(`앱 비밀번호 ${removed}개 지움`);
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`앱 비밀번호를 지우지 못해 멈춥니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`,
|
||||
);
|
||||
await fail(`앱 비밀번호 지우기 실패 · ${failureDetail(error)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 웹메일 세션 끊기 — 서버는 15초 안에 막는다
|
||||
let ended: number;
|
||||
try {
|
||||
ended = await this.data.revokeSessions(employee.email);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`,
|
||||
);
|
||||
await fail(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 받은편지함 알림 — 넣지 못해도 이어 간다. 만료된 계정에는 넣을 수 없다(계약 6절)
|
||||
let noticeId: string | null = null;
|
||||
if (employee.passwordExpired) {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
'받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.',
|
||||
);
|
||||
notes.push('알림 메일 건너뜀(이미 만료)');
|
||||
} else {
|
||||
noticeId = await this.systemMail.notifyPasswordReset(employee.email, {
|
||||
name: employee.name,
|
||||
});
|
||||
step(
|
||||
io,
|
||||
noticeId !== null,
|
||||
noticeId !== null
|
||||
? '받은편지함 알림 메일을 넣었습니다.'
|
||||
: '받은편지함 알림 메일을 넣지 못했습니다 — 재설정은 이어 갑니다.',
|
||||
);
|
||||
notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음');
|
||||
}
|
||||
|
||||
// 4. 엔진 — 임시 비밀번호 + 만료
|
||||
let password = '';
|
||||
try {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
password = generateTempPassword();
|
||||
try {
|
||||
await this.engine.resetPassword(employee.id, password);
|
||||
break;
|
||||
} catch (error) {
|
||||
const weak =
|
||||
error instanceof OpsEngineError && error.kind === 'weak-password';
|
||||
if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error;
|
||||
}
|
||||
}
|
||||
step(io, true, '임시 비밀번호와 만료를 걸었습니다.');
|
||||
} catch (error) {
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
if (isUnknownOutcome(error)) {
|
||||
// 엔진이 바꿨을 수 있다 — 알림 메일은 그대로 둔다(바뀌었다면 사실이다). 다시 하면 새 임시 비밀번호를 만든다
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`비밀번호를 바꿨는지 모릅니다(웹메일 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`,
|
||||
);
|
||||
await fail(`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`);
|
||||
return;
|
||||
}
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`비밀번호를 바꾸지 못했습니다(웹메일 로그인은 끊었습니다). ${failureLine(error)}`,
|
||||
);
|
||||
if (noticeId !== null) {
|
||||
const removed = await this.systemMail.remove(employee.email, noticeId);
|
||||
step(
|
||||
io,
|
||||
removed,
|
||||
removed
|
||||
? '넣었던 알림 메일을 지웠습니다.'
|
||||
: '넣었던 알림 메일을 지우지 못했습니다 — 직원에게 무시하라고 알려 주세요.',
|
||||
);
|
||||
}
|
||||
await fail(`비밀번호 바꾸기 실패 · ${failureDetail(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 그사이 옛 비밀번호로 새로 로그인한 세션
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
try {
|
||||
const late = await this.data.revokeSessions(employee.email);
|
||||
ended += late;
|
||||
if (late > 0) {
|
||||
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`그사이 새로 로그인한 세션을 확인하지 못했습니다 — 걱정되면 다시 「비밀번호 재설정」을 하세요. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
|
||||
showTempPassword(io, employee.email, password);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'password-reset',
|
||||
target: employee.email,
|
||||
result,
|
||||
detail: notes.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
/** 2단계 해제 — 휴대폰 분실: 엔진 OTP 지우기 + 켠 때 기록 정리 + 받은편지함 알림 */
|
||||
async resetTwoFactor(io: OpsIo, operator: string): Promise<void> {
|
||||
const employee = await askEmployee(io, this.engine, this.domain);
|
||||
if (employee === null) return;
|
||||
if (!employee.twoFactorEnabled) {
|
||||
io.print('그 직원은 2단계 인증이 꺼져 있습니다 — 할 것이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const requirement = twoFactorRequirementFor(
|
||||
(await this.settings.reload()).twoFactor,
|
||||
employee.email,
|
||||
new Date(),
|
||||
);
|
||||
const lines = [
|
||||
`대상 ${employee.name} <${employee.email}>`,
|
||||
'1. 엔진의 2단계 인증(OTP) 지우기 — 지금은 비밀번호만으로 로그인한다',
|
||||
'2. 웹메일의 2단계 인증 기록 정리',
|
||||
employee.passwordExpired
|
||||
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
|
||||
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
|
||||
' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저',
|
||||
...(requirement.isRequired
|
||||
? [
|
||||
' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다',
|
||||
]
|
||||
: []),
|
||||
];
|
||||
if (!(await confirmPlan(io, '2단계 해제', lines))) return;
|
||||
|
||||
try {
|
||||
await this.engine.removeTwoFactor(employee.id);
|
||||
step(io, true, '엔진의 2단계 인증을 지웠습니다.');
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'two-factor-reset',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: failureDetail(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const notes: string[] = [];
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
try {
|
||||
await this.data.forgetTwoFactor(employee.email);
|
||||
step(io, true, '웹메일의 2단계 인증 기록을 정리했습니다.');
|
||||
} catch (error) {
|
||||
// 엔진이 기준이다 — 다음 상태 읽기가 기록을 다시 맞춘다
|
||||
result = 'partial';
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`기록을 정리하지 못했습니다(다음 상태 읽기가 맞춥니다). ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`기록 정리 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
if (employee.passwordExpired) {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
'받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.',
|
||||
);
|
||||
notes.push('알림 메일 건너뜀(이미 만료)');
|
||||
} else {
|
||||
const noticeId = await this.systemMail.notifyTwoFactorReset(
|
||||
employee.email,
|
||||
{ name: employee.name },
|
||||
);
|
||||
step(
|
||||
io,
|
||||
noticeId !== null,
|
||||
noticeId !== null
|
||||
? '받은편지함 알림 메일을 넣었습니다.'
|
||||
: '받은편지함 알림 메일을 넣지 못했습니다.',
|
||||
);
|
||||
notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음');
|
||||
}
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'two-factor-reset',
|
||||
target: employee.email,
|
||||
result,
|
||||
detail: notes.join(' · '),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { SystemSettingsService } from '@/modules/system-settings/system-settings.service';
|
||||
import {
|
||||
checkSupportContact,
|
||||
type SystemSettings,
|
||||
} from '@/modules/system-settings/system-settings.types';
|
||||
import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date';
|
||||
import { OpsAuditService } from './ops-audit.service';
|
||||
import { OpsDataService } from './ops-data.service';
|
||||
import {
|
||||
OPS_ENGINE,
|
||||
type EngineEmployee,
|
||||
type EngineMailingList,
|
||||
type OpsEngine,
|
||||
} from './ops-engine.port';
|
||||
import type { OpsIo } from './ops-io';
|
||||
import {
|
||||
RULE,
|
||||
askAddress,
|
||||
confirmPlan,
|
||||
failureLine,
|
||||
logUnknownFailure,
|
||||
step,
|
||||
} from './ops-prompts';
|
||||
import {
|
||||
bytesText,
|
||||
failureDetail,
|
||||
timeText,
|
||||
toAddress,
|
||||
toDisplayName,
|
||||
} from './ops-text';
|
||||
|
||||
/** 공용 주소 하나의 구성원 상한 — 오타로 아주 긴 목록을 만들지 않게 */
|
||||
const MAILING_LIST_MAX_MEMBERS = 200;
|
||||
|
||||
/** 2단계 인증 필수 정책을 보이는 글 */
|
||||
export function policyText(settings: SystemSettings): string {
|
||||
const { twoFactor } = settings;
|
||||
if (!twoFactor.required) return '꺼짐';
|
||||
const start = twoFactor.startDate ?? '켠 때부터';
|
||||
const exempt = twoFactor.exempt.length;
|
||||
return `켜짐 · 시작일 ${start}${exempt > 0 ? ` · 예외 ${exempt}명` : ''}`;
|
||||
}
|
||||
|
||||
/** 현황의 한 줄 */
|
||||
export function employeeLine(
|
||||
employee: EngineEmployee,
|
||||
lastLogin: Date | undefined,
|
||||
): string {
|
||||
const usage =
|
||||
employee.quotaBytes === null
|
||||
? bytesText(employee.usedBytes)
|
||||
: `${bytesText(employee.usedBytes)} / ${bytesText(employee.quotaBytes)}`;
|
||||
const flags = [
|
||||
employee.locked ? '잠김' : null,
|
||||
employee.passwordExpired ? '임시 비밀번호' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return [
|
||||
`${employee.email} (${employee.name})`,
|
||||
` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`,
|
||||
` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usage}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구 — 관리 명령 (13단계 — 계약 6절 "운영 도구"): 링크 끊기 · 공용 주소 · 현황 · 정책 · 설정.
|
||||
* 바꾸는 명령은 실행 기록(ops_audit_log)을 남긴다. 신고된 링크의 주소(토큰이 들어 있다)는 보이지도 남기지도 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminCommands {
|
||||
constructor(
|
||||
@Inject(OPS_ENGINE) private readonly engine: OpsEngine,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly data: OpsDataService,
|
||||
private readonly audit: OpsAuditService,
|
||||
private readonly sharing: FileSharingService,
|
||||
private readonly settings: SystemSettingsService,
|
||||
) {}
|
||||
|
||||
private get domain(): string {
|
||||
return this.mailSettings.domain ?? '';
|
||||
}
|
||||
|
||||
/** 링크 끊기 — 신고된 공개 링크 주소로 → revoked_by = operator */
|
||||
async revokeLink(io: OpsIo, operator: string): Promise<void> {
|
||||
const url = await io.ask('신고된 공개 링크 주소 (https://…/s/…): ');
|
||||
// 우리 링크가 아니거나 없으면 null — DB 오류는 삼키지 않는다(그것을 "우리 링크 아님"으로 보이면 신고를 놓친다)
|
||||
let record: Awaited<ReturnType<FileSharingService['findForOperator']>>;
|
||||
try {
|
||||
record = await this.sharing.findForOperator(url);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return;
|
||||
}
|
||||
if (record === null) {
|
||||
io.print('우리 공개 링크 주소가 아니거나 없는(취소한) 링크입니다.');
|
||||
return;
|
||||
}
|
||||
const { link, files } = record;
|
||||
if (link.revokedAt !== null) {
|
||||
io.print(
|
||||
`이미 끊긴 링크입니다 (${timeText(link.revokedAt)} · ${link.revokedBy === 'operator' ? '운영자' : '보낸 사람'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`보낸 사람 ${link.senderName ?? ''} <${link.ownerEmail}>`,
|
||||
`보낸 때 ${timeText(link.sentAt)} · 끝나는 때 ${timeText(link.expiresAt)}`,
|
||||
`파일 ${files.length}개`,
|
||||
...files.map(
|
||||
(file) =>
|
||||
` ${file.name} (${bytesText(file.size)}) · 받은 수 ${file.downloadCount}`,
|
||||
),
|
||||
'끊으면 받는 사람이 더 내려받지 못한다(되돌릴 수 없다)',
|
||||
];
|
||||
if (!(await confirmPlan(io, '링크 끊기', lines))) return;
|
||||
try {
|
||||
const revoked = await this.sharing.revokeAsOperator(link.id);
|
||||
step(
|
||||
io,
|
||||
revoked,
|
||||
revoked
|
||||
? '링크를 끊었습니다.'
|
||||
: '끊지 않았습니다 — 그사이 끊겼거나 기간이 지났습니다.',
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'link-revoke',
|
||||
target: link.ownerEmail,
|
||||
result: revoked ? 'success' : 'failure',
|
||||
detail: `link=${link.id} · 파일 ${files.length}개${revoked ? '' : ' · 이미 끝난 링크'}`,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'link-revoke',
|
||||
target: link.ownerEmail,
|
||||
result: 'failure',
|
||||
detail: `link=${link.id} · ${failureDetail(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 공용 주소 — 목록 · 만들기 · 구성원 넣기 · 빼기 */
|
||||
async mailingLists(io: OpsIo, operator: string): Promise<void> {
|
||||
io.print(
|
||||
'공용 주소 — 1. 목록 2. 만들기 3. 구성원 넣기 4. 구성원 빼기 0. 돌아가기',
|
||||
);
|
||||
const choice = await io.ask('고르세요: ');
|
||||
try {
|
||||
if (choice === '1') await this.printMailingLists(io);
|
||||
else if (choice === '2') await this.createMailingList(io, operator);
|
||||
else if (choice === '3') await this.changeMember(io, operator, true);
|
||||
else if (choice === '4') await this.changeMember(io, operator, false);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async printMailingLists(io: OpsIo): Promise<void> {
|
||||
const lists = await this.engine.listMailingLists();
|
||||
if (lists.length === 0) {
|
||||
io.print('공용 주소가 없습니다.');
|
||||
return;
|
||||
}
|
||||
for (const list of lists) {
|
||||
io.print(
|
||||
`${list.email} (${list.name}) — 구성원 ${list.members.length}명`,
|
||||
);
|
||||
for (const member of list.members) io.print(` ${member}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createMailingList(io: OpsIo, operator: string): Promise<void> {
|
||||
const email = await askAddress(io, this.domain, '새 공용 주소');
|
||||
if (email === null) return;
|
||||
if (await this.engine.addressInUse(email)) {
|
||||
io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).');
|
||||
return;
|
||||
}
|
||||
const name = toDisplayName(await io.ask('이름 (예: 대표 문의): '));
|
||||
if (name === null) {
|
||||
io.print('이름은 한 줄 1~100자로 넣어 주세요.');
|
||||
return;
|
||||
}
|
||||
const members = await this.askMembers(io);
|
||||
if (members === null) return;
|
||||
const lines = [
|
||||
`주소 ${email}`,
|
||||
`이름 ${name}`,
|
||||
`구성원 ${members.join(', ')}`,
|
||||
'이 주소로 온 메일은 구성원 각자의 받은편지함에 사본으로 들어간다',
|
||||
];
|
||||
if (!(await confirmPlan(io, '공용 주소 만들기', lines))) return;
|
||||
try {
|
||||
await this.engine.createMailingList({
|
||||
localPart: email.slice(0, email.indexOf('@')),
|
||||
name,
|
||||
members,
|
||||
});
|
||||
step(io, true, '공용 주소를 만들었습니다.');
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'mailing-list',
|
||||
target: email,
|
||||
result: 'success',
|
||||
detail: `만들기 · 구성원 ${members.length}명`,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'mailing-list',
|
||||
target: email,
|
||||
result: 'failure',
|
||||
detail: `만들기 · ${failureDetail(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 구성원 — 쉼표로 나눈 사내 주소(엔진에 있는 직원) 1명 이상 */
|
||||
private async askMembers(io: OpsIo): Promise<string[] | null> {
|
||||
const input = await io.ask('구성원 주소 (쉼표로 나눠 여러 명): ');
|
||||
const members = [
|
||||
...new Set(
|
||||
input
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
.map((part) => toAddress(part, this.domain)),
|
||||
),
|
||||
];
|
||||
if (
|
||||
members.length === 0 ||
|
||||
members.length > MAILING_LIST_MAX_MEMBERS ||
|
||||
members.some((member) => member === null)
|
||||
) {
|
||||
io.print(
|
||||
`구성원은 @${this.domain} 주소로 1~${MAILING_LIST_MAX_MEMBERS}명 넣어 주세요.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const addresses = members as string[];
|
||||
for (const address of addresses) {
|
||||
if (!(await this.engine.findEmployee(address))) {
|
||||
io.print(`${address} — 엔진에 그 직원 계정이 없습니다.`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private async changeMember(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
include: boolean,
|
||||
): Promise<void> {
|
||||
const listEmail = await askAddress(io, this.domain, '공용 주소');
|
||||
if (listEmail === null) return;
|
||||
const list = (await this.engine.listMailingLists()).find(
|
||||
(item: EngineMailingList) => item.email === listEmail,
|
||||
);
|
||||
if (!list) {
|
||||
io.print('그런 공용 주소가 없습니다.');
|
||||
return;
|
||||
}
|
||||
const member = await askAddress(
|
||||
io,
|
||||
this.domain,
|
||||
include ? '넣을 구성원 주소' : '뺄 구성원 주소',
|
||||
);
|
||||
if (member === null) return;
|
||||
if (include && list.members.includes(member)) {
|
||||
io.print('이미 구성원입니다.');
|
||||
return;
|
||||
}
|
||||
if (!include && !list.members.includes(member)) {
|
||||
io.print('구성원이 아닙니다.');
|
||||
return;
|
||||
}
|
||||
if (include && !(await this.engine.findEmployee(member))) {
|
||||
io.print('엔진에 그 직원 계정이 없습니다.');
|
||||
return;
|
||||
}
|
||||
if (!include && list.members.length === 1) {
|
||||
io.print(
|
||||
'마지막 구성원은 뺄 수 없습니다 — 공용 주소에는 구성원이 한 명 이상 있어야 합니다.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const action = include ? '구성원 넣기' : '구성원 빼기';
|
||||
if (
|
||||
!(await confirmPlan(io, action, [
|
||||
`공용 주소 ${list.email} (${list.name})`,
|
||||
`${include ? '넣을' : '뺄'} 사람 ${member}`,
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.engine.setMailingListMember(list.id, member, include);
|
||||
step(io, true, `${action}를 했습니다.`);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'mailing-list',
|
||||
target: list.email,
|
||||
result: 'success',
|
||||
detail: `${action} · ${member}`,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'mailing-list',
|
||||
target: list.email,
|
||||
result: 'failure',
|
||||
detail: `${action} · ${member} · ${failureDetail(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 (읽기만 — 기록하지 않는다) */
|
||||
async status(io: OpsIo): Promise<void> {
|
||||
try {
|
||||
const [employees, settings] = await Promise.all([
|
||||
this.engine.listEmployees(),
|
||||
this.settings.reload(),
|
||||
]);
|
||||
const lastLogins = await this.data.lastWebmailLogins(
|
||||
employees.map((employee) => employee.email),
|
||||
);
|
||||
io.print('');
|
||||
io.print(RULE);
|
||||
io.print(` 직원 ${employees.length}명`);
|
||||
io.print(RULE);
|
||||
for (const employee of employees) {
|
||||
io.print(employeeLine(employee, lastLogins.get(employee.email)));
|
||||
}
|
||||
io.print(RULE);
|
||||
io.print(` 2단계 인증 필수 정책 ${policyText(settings)}`);
|
||||
io.print(
|
||||
` 문의 연락처 ${settings.supportContact ?? '(정하지 않음)'}`,
|
||||
);
|
||||
io.print(RULE);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** 정책 · 설정 — 2단계 인증 필수(켜기 · 시작일 · 예외), 문의 연락처 */
|
||||
async policy(io: OpsIo, operator: string): Promise<void> {
|
||||
const settings = await this.settings.reload();
|
||||
io.print(`2단계 인증 필수 정책: ${policyText(settings)}`);
|
||||
if (settings.twoFactor.exempt.length > 0) {
|
||||
io.print(` 예외: ${settings.twoFactor.exempt.join(', ')}`);
|
||||
}
|
||||
io.print(`문의 연락처: ${settings.supportContact ?? '(정하지 않음)'}`);
|
||||
io.print(
|
||||
'1. 2단계 인증 필수 켜기 · 시작일 2. 2단계 인증 필수 끄기 3. 예외 주소 넣기 4. 예외 주소 빼기 5. 문의 연락처 0. 돌아가기',
|
||||
);
|
||||
const choice = await io.ask('고르세요: ');
|
||||
try {
|
||||
if (choice === '1') await this.requireTwoFactor(io, operator);
|
||||
else if (choice === '2') await this.stopRequiringTwoFactor(io, operator);
|
||||
else if (choice === '3') await this.changeExempt(io, operator, true);
|
||||
else if (choice === '4') await this.changeExempt(io, operator, false);
|
||||
else if (choice === '5') await this.setSupportContact(io, operator);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async requireTwoFactor(io: OpsIo, operator: string): Promise<void> {
|
||||
const today = kstDateString(new Date());
|
||||
const input = await io.ask(
|
||||
`시작일 (YYYY-MM-DD, 한국 날짜 — 그날 0시부터 막는다. 빈 값이면 오늘 ${today}): `,
|
||||
);
|
||||
const startDate = input === '' ? today : input;
|
||||
if (kstStartOfDate(startDate) === null) {
|
||||
io.print('날짜는 2026-12-01 처럼 넣어 주세요.');
|
||||
return;
|
||||
}
|
||||
// 안내의 숫자만 — 읽지 못하면 그 줄을 뺀다(모르는 오류는 로그에)
|
||||
const employees = await this.engine.listEmployees().catch((error) => {
|
||||
logUnknownFailure(error);
|
||||
return null;
|
||||
});
|
||||
const withoutTwoFactor = employees?.filter(
|
||||
(employee) => !employee.locked && !employee.twoFactorEnabled,
|
||||
).length;
|
||||
const lines = [
|
||||
`시작일 ${startDate} 0시(KST) 부터 — 그전에는 웹메일이 안내만 한다`,
|
||||
'시작일부터 2단계를 켜지 않은 직원은 로그인 뒤 켜는 단계를 먼저 거친다(예외 주소 빼고)',
|
||||
'이미 로그인한 직원도 다음 갱신(최대 1시간) 때 적용된다',
|
||||
...(withoutTwoFactor !== undefined
|
||||
? [`지금 2단계를 켜지 않은 직원 ${withoutTwoFactor}명`]
|
||||
: []),
|
||||
...(startDate <= today
|
||||
? [
|
||||
'주의: 시작일이 오늘이거나 지났다 — 바로 적용된다(로그인한 직원은 다음 갱신 때)',
|
||||
]
|
||||
: []),
|
||||
];
|
||||
if (!(await confirmPlan(io, '2단계 인증 필수 켜기', lines))) return;
|
||||
await this.saveSettings(
|
||||
io,
|
||||
operator,
|
||||
'policy',
|
||||
'2단계 인증 필수',
|
||||
{ twoFactor: { required: true, startDate } },
|
||||
`켜기 · 시작일 ${startDate}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async stopRequiringTwoFactor(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!(await confirmPlan(io, '2단계 인증 필수 끄기', [
|
||||
'로그인 뒤 켜는 단계 · 웹메일의 안내가 없어진다(이미 켠 2단계 인증은 그대로)',
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.saveSettings(
|
||||
io,
|
||||
operator,
|
||||
'policy',
|
||||
'2단계 인증 필수',
|
||||
{ twoFactor: { required: false } },
|
||||
'끄기',
|
||||
);
|
||||
}
|
||||
|
||||
private async changeExempt(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
include: boolean,
|
||||
): Promise<void> {
|
||||
const email = await askAddress(
|
||||
io,
|
||||
this.domain,
|
||||
include ? '예외로 둘 직원 주소' : '예외에서 뺄 직원 주소',
|
||||
);
|
||||
if (email === null) return;
|
||||
const current = (await this.settings.reload()).twoFactor.exempt;
|
||||
if (include === current.includes(email)) {
|
||||
io.print(include ? '이미 예외입니다.' : '예외가 아닙니다.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await confirmPlan(io, include ? '예외 주소 넣기' : '예외 주소 빼기', [
|
||||
`주소 ${email}`,
|
||||
include
|
||||
? '이 직원은 2단계 인증 필수 정책에서 빠진다'
|
||||
: '이 직원도 2단계 인증 필수 정책을 따른다',
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// y/N 뒤에 서버 설정 줄을 잠그고 그때의 목록에 넣고 뺀다 — 묻는 동안 다른 운영자가 바꾼 것을 잃지 않게 (재리뷰 N5)
|
||||
const detail = include ? '예외 넣기' : '예외 빼기';
|
||||
try {
|
||||
const changed = await this.data.changeTwoFactorExempt(email, include);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
changed
|
||||
? '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.'
|
||||
: `그사이 이미 ${include ? '예외가 되었습니다' : '예외에서 빠졌습니다'} — 바꾼 것이 없습니다.`,
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'policy',
|
||||
target: email,
|
||||
result: 'success',
|
||||
detail: changed ? detail : `${detail} · 이미 그 상태`,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'policy',
|
||||
target: email,
|
||||
result: 'failure',
|
||||
detail: `${detail} · ${failureDetail(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async setSupportContact(io: OpsIo, operator: string): Promise<void> {
|
||||
const input = await io.ask(
|
||||
'문의 연락처 (한 줄 200자 — 예: IT 담당 홍길동 · 02-000-0000. 빈 값이면 지운다): ',
|
||||
);
|
||||
const checked = checkSupportContact(input);
|
||||
if (!checked.ok) {
|
||||
io.print(
|
||||
checked.problem === 'too-long'
|
||||
? '200자까지 넣을 수 있습니다.'
|
||||
: '한 줄로 넣어 주세요(줄바꿈 · 탭 없이).',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
checked.value === null
|
||||
? '연락처를 지운다 — 화면은 지금 글 그대로'
|
||||
: `연락처 ${checked.value}`,
|
||||
'로그인 화면 · 2단계 코드 단계 · 받은편지함 알림 메일의 마지막 줄에 글자로만 보인다',
|
||||
];
|
||||
if (!(await confirmPlan(io, '문의 연락처', lines))) return;
|
||||
await this.saveSettings(
|
||||
io,
|
||||
operator,
|
||||
'support-contact',
|
||||
null,
|
||||
{ supportContact: checked.value },
|
||||
checked.value === null ? '지우기' : '정하기',
|
||||
);
|
||||
}
|
||||
|
||||
private async saveSettings(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
command: 'policy' | 'support-contact',
|
||||
target: string | null,
|
||||
patch: Parameters<SystemSettingsService['update']>[0],
|
||||
detail: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.settings.update(patch, new Date());
|
||||
step(io, true, '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.');
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command,
|
||||
target,
|
||||
result: 'success',
|
||||
detail,
|
||||
});
|
||||
} catch (error) {
|
||||
step(io, false, failureLine(error));
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command,
|
||||
target,
|
||||
result: 'failure',
|
||||
detail: `${detail} · ${failureDetail(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileSharingService } from '@/modules/file-sharing/file-sharing.service';
|
||||
import {
|
||||
MAX_VACATION_SUBJECT_LENGTH,
|
||||
MAX_VACATION_TEXT_LENGTH,
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { AccountCommands } from './account-commands';
|
||||
import { OpsAuditService } from './ops-audit.service';
|
||||
import {
|
||||
OpsDataService,
|
||||
totalRows,
|
||||
type AddressPurge,
|
||||
type AddressRows,
|
||||
} from './ops-data.service';
|
||||
import {
|
||||
OPS_ENGINE,
|
||||
type EngineEmployee,
|
||||
type EngineMailingList,
|
||||
type LockRules,
|
||||
type OpsEngine,
|
||||
} from './ops-engine.port';
|
||||
import type { OpsIo } from './ops-io';
|
||||
import {
|
||||
NOT_EMPLOYEE_ACCOUNT,
|
||||
NOT_EMPLOYEE_ADDRESS,
|
||||
askAddress,
|
||||
askEmployee,
|
||||
confirmPlan,
|
||||
failureLine,
|
||||
logUnknownFailure,
|
||||
step,
|
||||
} from './ops-prompts';
|
||||
import { bytesText, failureDetail, isUnknownOutcome } from './ops-text';
|
||||
|
||||
/** 엔진이 Sieve 문자열에서 늘 변수로 펼치는 글 — 자동 응답에 받지 않는다 (sieve-script.ts 와 같은 규칙) */
|
||||
const SIEVE_VARIABLE = '${';
|
||||
/** 자동 응답 내용의 한 줄로 받지 않는 제어 문자 (줄바꿈은 따로 잇는다) */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
|
||||
/** 표의 이름 — 퇴사 마무리에서 지울 줄을 보일 때 */
|
||||
const ROW_LABELS: Record<keyof AddressRows, string> = {
|
||||
loginEvents: '로그인 기록',
|
||||
appPasswordUsages: '앱 비밀번호 사용 기록',
|
||||
twoFactorSettings: '2단계 인증 기록',
|
||||
signatures: '서명',
|
||||
signatureImages: '서명 그림',
|
||||
mailSettings: '보내기 · 읽기 설정',
|
||||
directory: '사내 목록 기록',
|
||||
files: '대용량 파일',
|
||||
shareLinks: '대용량 첨부 링크',
|
||||
};
|
||||
|
||||
/** 남은 줄을 보이는 글 */
|
||||
export function rowsLines(rows: AddressRows): string[] {
|
||||
return (Object.keys(ROW_LABELS) as (keyof AddressRows)[])
|
||||
.filter((key) => rows[key] > 0)
|
||||
.map((key) => ` ${ROW_LABELS[key]} ${rows[key]}줄`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 퇴사 마무리가 그 주소를 지울 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 (바뀔 것에 보이고 y/N 뒤에 지운다).
|
||||
* `keptLists` 는 그 직원이 마지막 구성원이라 운영자가 남기기로 한 공용 주소다(재리뷰 N4)
|
||||
*/
|
||||
interface AddressFootprint {
|
||||
readonly rows: AddressRows;
|
||||
readonly exempt: boolean;
|
||||
readonly lists: readonly EngineMailingList[];
|
||||
readonly keptLists: readonly EngineMailingList[];
|
||||
}
|
||||
|
||||
/** 그 직원이 마지막 구성원인가 — 빼면 받는 사람이 없다 */
|
||||
function isLastMember(list: EngineMailingList): boolean {
|
||||
return list.members.length === 1;
|
||||
}
|
||||
|
||||
function footprintEmpty(footprint: AddressFootprint): boolean {
|
||||
return (
|
||||
totalRows(footprint.rows) === 0 &&
|
||||
!footprint.exempt &&
|
||||
footprint.lists.length === 0 &&
|
||||
footprint.keptLists.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 퇴사 마무리의 바뀔 것 — 우리 DB · 예외 주소 · 공용 주소 */
|
||||
function footprintLines(footprint: AddressFootprint, first: number): string[] {
|
||||
const lines = [
|
||||
`${first}. 우리 DB 의 그 주소 기록 지우기 — 같은 주소를 다시 쓸 때 섞이지 않게`,
|
||||
...rowsLines(footprint.rows),
|
||||
...(footprint.exempt ? [' 2단계 인증 필수의 예외 주소에서 빼기'] : []),
|
||||
' 웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다',
|
||||
];
|
||||
if (footprint.lists.length > 0) {
|
||||
lines.push(
|
||||
`${first + 1}. 공용 주소 ${footprint.lists.length}개의 구성원에서 빼기 — ${footprint.lists.map((list) => list.email).join(', ')}`,
|
||||
...footprint.lists
|
||||
.filter(isLastMember)
|
||||
.map(
|
||||
(list) =>
|
||||
` ${list.email} — 마지막 구성원이다(빼면 받는 사람이 없다)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
...footprint.keptLists.map(
|
||||
(list) =>
|
||||
` 남김: ${list.email} — 마지막 구성원(다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)`,
|
||||
),
|
||||
' 실행 기록(운영 도구)은 남긴다',
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구 — 잠금 명령 (13단계 — 계약 6절 "운영 도구"): 잠금 · 잠금 풀기 · 퇴사 마무리.
|
||||
* - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근 Sieve)을 먼저 → 로그인 권한(authenticate) 빼기 → 웹메일 세션 끊기 → (묻고) 공개 링크 끊기.
|
||||
* 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). 메일은 계속 받는다
|
||||
* - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다
|
||||
* - 퇴사 마무리: 잠긴 계정만 — 주소를 다시 쳐서 확인한 뒤 엔진 계정 지우기(메일 영구 삭제) → 우리 DB 의 그 주소 줄 정리
|
||||
*/
|
||||
@Injectable()
|
||||
export class LockCommands {
|
||||
constructor(
|
||||
@Inject(OPS_ENGINE) private readonly engine: OpsEngine,
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly data: OpsDataService,
|
||||
private readonly audit: OpsAuditService,
|
||||
private readonly sharing: FileSharingService,
|
||||
private readonly accounts: AccountCommands,
|
||||
) {}
|
||||
|
||||
private get domain(): string {
|
||||
return this.mailSettings.domain ?? '';
|
||||
}
|
||||
|
||||
async lock(io: OpsIo, operator: string): Promise<void> {
|
||||
const employee = await askEmployee(io, this.engine, this.domain);
|
||||
if (employee === null) return;
|
||||
if (employee.locked) {
|
||||
io.print('이미 잠긴 계정입니다.');
|
||||
return;
|
||||
}
|
||||
if (employee.customPermissions) {
|
||||
io.print(
|
||||
'이 계정은 권한이 직접 정해져 있어 이 도구로 잠그지 않습니다 — 엔진 관리 화면에서 하세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = await this.askLockRules(io, employee);
|
||||
if (rules === null) return;
|
||||
const revokeLinks = await io.confirm(
|
||||
'그 직원이 보낸 대용량 첨부 공개 링크도 모두 끊을까요?',
|
||||
);
|
||||
|
||||
const lines = [
|
||||
`대상 ${employee.name} <${employee.email}>`,
|
||||
...(rules.forwardTo !== null
|
||||
? [`1. 받는 메일의 사본을 ${rules.forwardTo} 로 넘기기 (원본도 남는다)`]
|
||||
: []),
|
||||
...(rules.autoReply !== null
|
||||
? [`2. 자동 응답 — 제목 「${rules.autoReply.subject}」`]
|
||||
: []),
|
||||
'3. 로그인 권한 빼기 — 웹메일 · 메일 프로그램 · 앱 비밀번호가 모두 막힌다. 메일은 계속 받는다',
|
||||
'4. 웹메일 로그인 모두 끊기',
|
||||
...(revokeLinks ? ['5. 보낸 대용량 첨부 공개 링크 모두 끊기'] : []),
|
||||
];
|
||||
if (!(await confirmPlan(io, '잠금', lines))) return;
|
||||
|
||||
const notes: string[] = [];
|
||||
const sieveRequested = rules.forwardTo !== null || rules.autoReply !== null;
|
||||
// 1 · 2. Sieve — 로그인 권한을 빼기 전에 (뺀 뒤에는 대리 접근이 막힌다)
|
||||
if (sieveRequested) {
|
||||
try {
|
||||
await this.engine.applyLockRules(employee.email, rules);
|
||||
step(io, true, '받는 메일 넘기기 · 자동 응답을 걸었습니다.');
|
||||
notes.push(
|
||||
[
|
||||
rules.forwardTo !== null ? `넘기기 → ${rules.forwardTo}` : null,
|
||||
rules.autoReply !== null ? '자동 응답' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
);
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`넘기기 · 자동 응답을 걸지 못해 멈춥니다 — 잠그지 않았습니다. ${failureLine(error)}`,
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'lock',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: `Sieve 실패 · ${failureDetail(error)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 로그인 권한 빼기
|
||||
try {
|
||||
await this.engine.setLoginEnabled(employee.id, false);
|
||||
step(io, true, '로그인 권한을 뺐습니다.');
|
||||
} catch (error) {
|
||||
const unknown = isUnknownOutcome(error);
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
unknown
|
||||
? `잠갔는지 모릅니다 — ${failureLine(error)}`
|
||||
: `잠그지 못했습니다(로그인 권한을 빼지 못함). ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(
|
||||
`잠금 ${unknown ? '결과 모름' : '실패'} · ${failureDetail(error)}`,
|
||||
);
|
||||
// 결과를 모르면 잠겼을 수 있다 — 걸어 둔 넘기기 · 자동 응답은 그대로 둔다(다시 「잠금」을 하면 이어 쓴다)
|
||||
await this.afterLockFailed(
|
||||
io,
|
||||
employee,
|
||||
sieveRequested && !unknown,
|
||||
notes,
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'lock',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: notes.join(' · '),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4 · 5. 세션 · 링크
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
try {
|
||||
const ended = await this.data.revokeSessions(employee.email);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(io, false, `웹메일 로그인을 끊지 못했습니다. ${failureLine(error)}`);
|
||||
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
if (revokeLinks) {
|
||||
try {
|
||||
const revoked = await this.sharing.revokeAllOfOwnerAsOperator(
|
||||
employee.email,
|
||||
);
|
||||
step(io, true, `공개 링크 ${revoked}개를 끊었습니다.`);
|
||||
notes.push(`링크 ${revoked}개 끊음`);
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(io, false, `공개 링크를 끊지 못했습니다. ${failureLine(error)}`);
|
||||
notes.push(`링크 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
}
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'lock',
|
||||
target: employee.email,
|
||||
result,
|
||||
detail: notes.join(' · ') || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 권한을 빼지 못했다 — 웹메일 세션은 끊어 둔다(잠그려던 계정이다 — 털림 · 퇴사, 다시 「잠금」을 하면 된다).
|
||||
* 걸어 둔 넘기기 · 자동 응답은 계정이 그대로 쓰이므로 뗄지 묻는다
|
||||
*/
|
||||
private async afterLockFailed(
|
||||
io: OpsIo,
|
||||
employee: EngineEmployee,
|
||||
sieveApplied: boolean,
|
||||
notes: string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ended = await this.data.revokeSessions(employee.email);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`,
|
||||
);
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
} catch (error) {
|
||||
step(io, false, `웹메일 로그인도 끊지 못했습니다. ${failureLine(error)}`);
|
||||
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
if (
|
||||
!sieveApplied ||
|
||||
!(await io.confirm(
|
||||
'걸어 둔 받는 메일 넘기기 · 자동 응답을 뗄까요? (잠그지 못해 계정은 그대로 쓰입니다)',
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.engine.clearLockRules(employee.email);
|
||||
step(io, true, '넘기기 · 자동 응답을 뗐습니다.');
|
||||
notes.push('Sieve 뗌');
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`떼지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`Sieve 떼기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 넘기기 · 자동 응답을 묻는다 — 잘못 넣으면 null(잠그지 않는다) */
|
||||
private async askLockRules(
|
||||
io: OpsIo,
|
||||
employee: EngineEmployee,
|
||||
): Promise<LockRules | null> {
|
||||
let forwardTo: string | null = null;
|
||||
if (await io.confirm('받는 메일의 사본을 동료에게 넘길까요?')) {
|
||||
const target = await askEmployee(
|
||||
io,
|
||||
this.engine,
|
||||
this.domain,
|
||||
'받을 동료 주소',
|
||||
);
|
||||
if (target === null) return null;
|
||||
if (target.email === employee.email) {
|
||||
io.print('자기 자신에게는 넘기지 않습니다.');
|
||||
return null;
|
||||
}
|
||||
if (target.locked) {
|
||||
io.print('그 동료도 잠긴 계정입니다 — 다른 주소를 고르세요.');
|
||||
return null;
|
||||
}
|
||||
forwardTo = target.email;
|
||||
}
|
||||
let autoReply: LockRules['autoReply'] = null;
|
||||
if (await io.confirm('보낸 사람에게 자동 응답을 보낼까요?')) {
|
||||
const subject = (await io.ask('자동 응답 제목: ')).trim();
|
||||
if (
|
||||
subject === '' ||
|
||||
subject.length > MAX_VACATION_SUBJECT_LENGTH ||
|
||||
CONTROL_CHARACTER.test(subject) ||
|
||||
subject.includes(SIEVE_VARIABLE)
|
||||
) {
|
||||
io.print(
|
||||
`제목은 한 줄 1~${MAX_VACATION_SUBJECT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
io.print(
|
||||
'자동 응답 내용 — 여러 줄을 넣을 수 있습니다. 빈 줄을 넣으면 끝납니다.',
|
||||
);
|
||||
const lines: string[] = [];
|
||||
for (;;) {
|
||||
const line = await io.ask('> ');
|
||||
if (line === '') break;
|
||||
lines.push(line);
|
||||
}
|
||||
const body = lines.join('\n');
|
||||
if (
|
||||
body === '' ||
|
||||
body.length > MAX_VACATION_TEXT_LENGTH ||
|
||||
lines.some((line) => CONTROL_CHARACTER.test(line)) ||
|
||||
body.includes(SIEVE_VARIABLE)
|
||||
) {
|
||||
io.print(
|
||||
`내용은 1~${MAX_VACATION_TEXT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
autoReply = { subject, body };
|
||||
}
|
||||
return { forwardTo, autoReply };
|
||||
}
|
||||
|
||||
async unlock(io: OpsIo, operator: string): Promise<void> {
|
||||
const employee = await askEmployee(io, this.engine, this.domain);
|
||||
if (employee === null) return;
|
||||
if (!employee.locked) {
|
||||
io.print('잠긴 계정이 아닙니다.');
|
||||
return;
|
||||
}
|
||||
if (employee.customPermissions) {
|
||||
io.print(
|
||||
'이 계정은 권한이 직접 정해져 있어 이 도구로 풀지 않습니다 — 엔진 관리 화면에서 하세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`대상 ${employee.name} <${employee.email}>`,
|
||||
'1. 로그인 권한 되돌리기 — 지금 비밀번호 · 앱 비밀번호로 다시 들어올 수 있다',
|
||||
' 잠글 때 건 넘기기 · 자동 응답은 푼 뒤에 뗄지 묻는다',
|
||||
' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)',
|
||||
];
|
||||
if (!(await confirmPlan(io, '잠금 풀기', lines))) return;
|
||||
|
||||
try {
|
||||
await this.engine.setLoginEnabled(employee.id, true);
|
||||
step(io, true, '로그인 권한을 되돌렸습니다.');
|
||||
} catch (error) {
|
||||
step(io, false, `풀지 못했습니다. ${failureLine(error)}`);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'unlock',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: failureDetail(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const notes: string[] = [];
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
try {
|
||||
const rules = await this.engine.getLockRules(employee.email);
|
||||
if (rules.forwardTo !== null || rules.autoReply) {
|
||||
const what = [
|
||||
rules.forwardTo !== null ? `넘기기(→ ${rules.forwardTo})` : null,
|
||||
rules.autoReply ? '자동 응답' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
if (await io.confirm(`걸려 있는 것을 뗄까요? — ${what}`)) {
|
||||
await this.engine.clearLockRules(employee.email);
|
||||
step(io, true, `뗐습니다 — ${what}`);
|
||||
notes.push(`${what} 뗌`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`넘기기 · 자동 응답을 확인하지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`Sieve 확인 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'unlock',
|
||||
target: employee.email,
|
||||
result,
|
||||
detail: notes.join(' · ') || null,
|
||||
});
|
||||
|
||||
if (
|
||||
await io.confirm('비밀번호 재설정을 함께 할까요? (털린 계정이면 권함)')
|
||||
) {
|
||||
const refreshed = await this.engine
|
||||
.findEmployee(employee.email)
|
||||
.catch((error) => {
|
||||
logUnknownFailure(error);
|
||||
return null;
|
||||
});
|
||||
if (refreshed === null) {
|
||||
io.print(
|
||||
'계정을 다시 읽지 못했습니다 — 「비밀번호 재설정」을 따로 하세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await confirmPlan(
|
||||
io,
|
||||
'비밀번호 재설정',
|
||||
this.accounts.resetPlan(refreshed),
|
||||
)
|
||||
) {
|
||||
await this.accounts.runPasswordReset(io, operator, refreshed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async offboard(io: OpsIo, operator: string): Promise<void> {
|
||||
const email = await askAddress(io, this.domain, '퇴사한 직원 주소');
|
||||
if (email === null) return;
|
||||
if (!this.engine.isEmployeeAddress(email)) {
|
||||
io.print(NOT_EMPLOYEE_ADDRESS);
|
||||
return;
|
||||
}
|
||||
let employee: EngineEmployee | null;
|
||||
let footprint: AddressFootprint;
|
||||
try {
|
||||
employee = await this.engine.findEmployee(email);
|
||||
// 계정은 있는데 직원(User)이 아니다 — 공유 메일함(Group) · 공용 주소. 이 도구로 지우지 않는다
|
||||
if (employee === null && (await this.engine.addressInUse(email))) {
|
||||
io.print(NOT_EMPLOYEE_ACCOUNT);
|
||||
return;
|
||||
}
|
||||
footprint = await this.footprintOf(email);
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return;
|
||||
}
|
||||
footprint = await this.chooseLastMemberLists(io, footprint);
|
||||
|
||||
if (employee === null) {
|
||||
// 엔진 계정은 이미 없다(지난 마무리가 DB 정리 전에 멈췄거나 엔진 관리 화면에서 지웠다) — 남은 것만 지운다
|
||||
if (footprintEmpty(footprint)) {
|
||||
io.print('엔진에 그 계정이 없고 우리 DB 에 남은 기록도 없습니다.');
|
||||
return;
|
||||
}
|
||||
io.print('엔진에 그 계정이 없습니다 — 남은 기록만 지웁니다.');
|
||||
await this.purgeOnly(io, operator, email, footprint);
|
||||
return;
|
||||
}
|
||||
if (!employee.locked) {
|
||||
io.print('잠긴 계정만 마무리할 수 있습니다 — 먼저 「잠금」을 하세요.');
|
||||
return;
|
||||
}
|
||||
const lines = [
|
||||
`대상 ${employee.name} <${employee.email}>`,
|
||||
`1. 엔진 계정 지우기 — 메일 ${bytesText(employee.usedBytes)} 이 영구 삭제된다(되돌릴 수 없다)`,
|
||||
...footprintLines(footprint, 2),
|
||||
];
|
||||
if (!(await confirmPlan(io, '퇴사 마무리', lines))) return;
|
||||
if (!(await this.retyped(io, employee.email))) return;
|
||||
|
||||
try {
|
||||
await this.engine.deleteAccount(employee.id);
|
||||
step(io, true, '엔진 계정을 지웠습니다.');
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
isUnknownOutcome(error)
|
||||
? `엔진 계정을 지웠는지 모릅니다 — ${failureLine(error)} 이미 지워졌으면 다시 할 때 남은 기록만 지웁니다.`
|
||||
: `엔진 계정을 지우지 못했습니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`,
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'offboard',
|
||||
target: employee.email,
|
||||
result: 'failure',
|
||||
detail: failureDetail(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.purge(io, operator, employee.email, footprint, '엔진 계정 지움');
|
||||
}
|
||||
|
||||
/** 그 주소가 남은 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 */
|
||||
private async footprintOf(email: string): Promise<AddressFootprint> {
|
||||
const [rows, exempt, lists] = await Promise.all([
|
||||
this.data.countRowsOf(email),
|
||||
this.data.isTwoFactorExempt(email),
|
||||
this.engine.listMailingLists(),
|
||||
]);
|
||||
return {
|
||||
rows,
|
||||
exempt,
|
||||
lists: lists.filter((list) => list.members.includes(email)),
|
||||
keptLists: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 직원이 마지막 구성원인 공용 주소 — 빼면 받는 사람이 없다(「공용 주소」 명령은 마지막 구성원을 빼지 않는다). 그래도 뺄지 묻는다 (재리뷰 N4)
|
||||
*/
|
||||
private async chooseLastMemberLists(
|
||||
io: OpsIo,
|
||||
footprint: AddressFootprint,
|
||||
): Promise<AddressFootprint> {
|
||||
const last = footprint.lists.filter(isLastMember);
|
||||
if (last.length === 0) return footprint;
|
||||
io.print(
|
||||
`그 직원이 마지막 구성원인 공용 주소가 있습니다 — 빼면 받는 사람이 없습니다: ${last.map((list) => list.email).join(', ')}`,
|
||||
);
|
||||
if (
|
||||
await io.confirm(
|
||||
'그 공용 주소에서도 뺄까요? (n 이면 남깁니다 — 다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)',
|
||||
)
|
||||
) {
|
||||
return footprint;
|
||||
}
|
||||
return {
|
||||
...footprint,
|
||||
lists: footprint.lists.filter((list) => !isLastMember(list)),
|
||||
keptLists: last,
|
||||
};
|
||||
}
|
||||
|
||||
/** 엔진 계정이 없는 주소의 남은 것만 지운다 */
|
||||
private async purgeOnly(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
email: string,
|
||||
footprint: AddressFootprint,
|
||||
): Promise<void> {
|
||||
const lines = [`주소 ${email}`, ...footprintLines(footprint, 1)];
|
||||
if (!(await confirmPlan(io, '퇴사 마무리(기록만)', lines))) return;
|
||||
if (!(await this.retyped(io, email))) return;
|
||||
await this.purge(io, operator, email, footprint, '엔진 계정 없음');
|
||||
}
|
||||
|
||||
/** 우리 DB(한 트랜잭션 — 예외 주소 포함) → 공용 주소 구성원 → 실행 기록 */
|
||||
private async purge(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
email: string,
|
||||
footprint: AddressFootprint,
|
||||
first: string,
|
||||
): Promise<void> {
|
||||
const notes = [first];
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
let purged: AddressPurge;
|
||||
try {
|
||||
purged = await this.data.purgeAddress(email);
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`우리 DB 의 기록을 지우지 못했습니다 — 「퇴사 마무리」를 다시 하면 남은 것만 지웁니다. ${failureLine(error)}`,
|
||||
);
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'offboard',
|
||||
target: email,
|
||||
result: 'partial',
|
||||
detail: [...notes, `기록 지우기 실패 · ${failureDetail(error)}`].join(
|
||||
' · ',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
step(io, true, `우리 DB 의 기록 ${totalRows(purged.rows)}줄을 지웠습니다.`);
|
||||
notes.push(`기록 ${totalRows(purged.rows)}줄 지움`);
|
||||
if (purged.exemptRemoved) {
|
||||
step(io, true, '2단계 인증 필수의 예외 주소에서 뺐습니다.');
|
||||
notes.push('예외 주소에서 뺌');
|
||||
}
|
||||
if (purged.sessionsRevoked > 0) {
|
||||
notes.push(`세션 ${purged.sessionsRevoked}개 끊음`);
|
||||
}
|
||||
if (purged.filesLeftForCleanup > 0) {
|
||||
io.print(
|
||||
` 디스크의 파일 ${purged.filesLeftForCleanup}개는 정리 작업이 지웁니다.`,
|
||||
);
|
||||
}
|
||||
for (const list of footprint.lists) {
|
||||
try {
|
||||
await this.engine.setMailingListMember(list.id, email, false);
|
||||
step(io, true, `공용 주소 ${list.email} 의 구성원에서 뺐습니다.`);
|
||||
notes.push(`${list.email} 구성원에서 뺌`);
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`공용 주소 ${list.email} 의 구성원에서 빼지 못했습니다 — 「공용 주소」에서 빼 주세요. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`${list.email} 빼기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
}
|
||||
await this.audit.record({
|
||||
operator,
|
||||
command: 'offboard',
|
||||
target: email,
|
||||
result,
|
||||
detail: notes.join(' · '),
|
||||
});
|
||||
}
|
||||
|
||||
/** 주소를 다시 쳐서 확인 — 정확히 같아야 한다 (소문자로 견준다) */
|
||||
private async retyped(io: OpsIo, email: string): Promise<boolean> {
|
||||
const typed = (
|
||||
await io.ask('지우려면 주소 전체를 다시 쳐 주세요: ')
|
||||
).toLowerCase();
|
||||
if (typed !== email) {
|
||||
io.print('주소가 같지 않아 하지 않았습니다.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
/** 운영자 이름 길이 */
|
||||
export const OPS_OPERATOR_MAX_LENGTH = 100;
|
||||
/** 명령 이름 길이 */
|
||||
const COMMAND_COLUMN_LENGTH = 32;
|
||||
/** 대상 — 주소 · 공용 주소 · 설정 이름 (메일 주소 최대 길이) */
|
||||
export const OPS_TARGET_MAX_LENGTH = 254;
|
||||
/** 결과 — success · failure · partial */
|
||||
const RESULT_COLUMN_LENGTH = 16;
|
||||
/** 덧붙이는 말 — 비밀은 넣지 않는다 */
|
||||
export const OPS_DETAIL_MAX_LENGTH = 500;
|
||||
|
||||
/** 실행 결과 */
|
||||
export type OpsAuditResult = 'success' | 'failure' | 'partial';
|
||||
|
||||
/**
|
||||
* 운영 도구의 실행 기록 (13단계 — 계약 6절 "운영 도구") — 언제 · 누가 · 명령 · 대상 · 결과.
|
||||
* 비밀(관리자 비밀번호 · 임시 비밀번호 · 코드)은 남기지 않는다. 퇴사 마무리도 이 표는 지우지 않는다(DB 백업에 든다)
|
||||
*/
|
||||
@Entity({ name: 'ops_audit_log' })
|
||||
export class OpsAuditLogEntity {
|
||||
/** 차례 번호 (bigint — 문자열로 읽힌다) */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Index('IDX_ops_audit_log_occurred_at')
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
/** 운영자 이름 — 도구를 시작할 때 넣은 이름 */
|
||||
@Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH })
|
||||
operator!: string;
|
||||
|
||||
/** 명령 — hire · password-reset · two-factor-reset · lock · unlock · offboard · link-revoke · mailing-list · policy · support-contact */
|
||||
@Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH })
|
||||
command!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: OPS_TARGET_MAX_LENGTH, nullable: true })
|
||||
target!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: RESULT_COLUMN_LENGTH })
|
||||
result!: OpsAuditResult;
|
||||
|
||||
@Column({ type: 'varchar', length: OPS_DETAIL_MAX_LENGTH, nullable: true })
|
||||
detail!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
OPS_DETAIL_MAX_LENGTH,
|
||||
OPS_OPERATOR_MAX_LENGTH,
|
||||
OPS_TARGET_MAX_LENGTH,
|
||||
OpsAuditLogEntity,
|
||||
type OpsAuditResult,
|
||||
} from './ops-audit-log.entity';
|
||||
|
||||
/** 실행 기록 한 줄 — 비밀(비밀번호 · 코드)은 넣지 않는다 */
|
||||
export interface OpsAuditEntry {
|
||||
readonly operator: string;
|
||||
readonly command: string;
|
||||
readonly target: string | null;
|
||||
readonly result: OpsAuditResult;
|
||||
readonly detail?: string | null;
|
||||
}
|
||||
|
||||
function clip(text: string | null | undefined, max: number): string | null {
|
||||
if (text === null || text === undefined || text === '') return null;
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구의 실행 기록 (계약 6절 — ops_audit_log). 기록하지 못해도 명령의 결과는 그대로다 — 남기지 못했다는 것을 알린다(false)
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpsAuditService {
|
||||
private readonly logger = new Logger(OpsAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(OpsAuditLogEntity)
|
||||
private readonly rows: Repository<OpsAuditLogEntity>,
|
||||
) {}
|
||||
|
||||
async record(entry: OpsAuditEntry, at: Date = new Date()): Promise<boolean> {
|
||||
try {
|
||||
await this.rows.insert({
|
||||
occurredAt: at,
|
||||
operator:
|
||||
clip(entry.operator, OPS_OPERATOR_MAX_LENGTH) ?? '(이름 없음)',
|
||||
command: entry.command,
|
||||
target: clip(entry.target, OPS_TARGET_MAX_LENGTH),
|
||||
result: entry.result,
|
||||
detail: clip(entry.detail, OPS_DETAIL_MAX_LENGTH),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 운영 도구의 실행 기록을 남기지 못했습니다: command=${entry.command}, reason=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { AccountCommands } from './account-commands';
|
||||
import { AdminCommands } from './admin-commands';
|
||||
import { LockCommands } from './lock-commands';
|
||||
import { OPS_OPERATOR_MAX_LENGTH } from './ops-audit-log.entity';
|
||||
import { OPS_ENGINE, OpsEngineError, type OpsEngine } from './ops-engine.port';
|
||||
import { OpsInputClosed, type OpsIo } from './ops-io';
|
||||
import { RULE, failureLine } from './ops-prompts';
|
||||
|
||||
/** 운영자 이름에 받지 않는 제어 문자 */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const OPERATOR_CONTROL = /[\u0000-\u001f]/;
|
||||
|
||||
/** 관리자 비밀번호를 다시 묻는 횟수 */
|
||||
const SIGN_IN_ATTEMPTS = 3;
|
||||
|
||||
/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열 개와 차례 그대로) */
|
||||
export const OPS_MENU = [
|
||||
['1', '입사'],
|
||||
['2', '비밀번호 재설정'],
|
||||
['3', '2단계 해제'],
|
||||
['4', '잠금'],
|
||||
['5', '잠금 풀기'],
|
||||
['6', '퇴사 마무리'],
|
||||
['7', '링크 끊기'],
|
||||
['8', '공용 주소'],
|
||||
['9', '현황'],
|
||||
['10', '정책 · 설정'],
|
||||
['0', '끝내기'],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 운영 도구의 차례 (13단계 — 계약 6절 "운영 도구") — 운영자 이름 · 관리자 비밀번호(화면에 보이지 않게 · 저장하지 않음)를 묻고 메뉴를 돈다.
|
||||
* 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpsCli {
|
||||
constructor(
|
||||
@Inject(OPS_ENGINE) private readonly engine: OpsEngine,
|
||||
private readonly accounts: AccountCommands,
|
||||
private readonly locks: LockCommands,
|
||||
private readonly admin: AdminCommands,
|
||||
) {}
|
||||
|
||||
/** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */
|
||||
async run(io: OpsIo): Promise<number> {
|
||||
try {
|
||||
io.print(RULE);
|
||||
io.print(' ARA SYSTEM 운영 도구');
|
||||
io.print(
|
||||
' 바꾸기 전에 늘 바뀔 것을 보여 주고 y/N 으로 묻습니다. 한 일은 실행 기록에 남습니다.',
|
||||
);
|
||||
io.print(RULE);
|
||||
if (!io.interactive) {
|
||||
// 비밀번호를 묻기 전에 — 파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다
|
||||
io.print(
|
||||
'이 도구는 터미널에서 사람이 돌립니다 — 관리자 비밀번호를 화면에 보이지 않게 묻기 위해서입니다.',
|
||||
);
|
||||
io.print(
|
||||
' sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js',
|
||||
);
|
||||
io.print(
|
||||
'(-it 를 빠뜨리면 표준 입력이 터미널이 아니라 이 안내가 나옵니다)',
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
if (io.pipedForTesting) {
|
||||
io.print(
|
||||
'주의: 터미널이 아닌 입력으로 돌고 있습니다(OPS_ALLOW_NO_TTY=1 — 자동 시험용). 사람은 docker exec -it 로 실행하세요.',
|
||||
);
|
||||
}
|
||||
const operator = await this.askOperator(io);
|
||||
if (operator === null) return 1;
|
||||
if (!(await this.signIn(io))) return 1;
|
||||
await this.menu(io, operator);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof OpsInputClosed) {
|
||||
io.print('');
|
||||
io.print('끝냅니다.');
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async askOperator(io: OpsIo): Promise<string | null> {
|
||||
for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) {
|
||||
const name = (
|
||||
await io.ask('운영자 이름 (실행 기록에 남습니다): ')
|
||||
).trim();
|
||||
if (
|
||||
name !== '' &&
|
||||
name.length <= OPS_OPERATOR_MAX_LENGTH &&
|
||||
!OPERATOR_CONTROL.test(name)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
io.print(`이름은 한 줄 1~${OPS_OPERATOR_MAX_LENGTH}자로 넣어 주세요.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async signIn(io: OpsIo): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) {
|
||||
const password = await io.askSecret(
|
||||
'메일 엔진 관리자 비밀번호 (화면에 보이지 않습니다 · 저장하지 않습니다): ',
|
||||
);
|
||||
if (password === '') continue;
|
||||
try {
|
||||
const result = await this.engine.signIn(password);
|
||||
if (result === 'ok') return true;
|
||||
if (result === 'two-factor') {
|
||||
io.print(
|
||||
'관리자 계정에 2단계 인증이 켜져 있어 이 도구로 들어갈 수 없습니다.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (result === 'not-admin') {
|
||||
io.print('그 계정에는 계정 관리 권한이 없습니다.');
|
||||
return false;
|
||||
}
|
||||
io.print('관리자 비밀번호가 맞지 않습니다.');
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
io.print('들어가지 못했습니다.');
|
||||
return false;
|
||||
}
|
||||
|
||||
private async menu(io: OpsIo, operator: string): Promise<void> {
|
||||
for (;;) {
|
||||
io.print('');
|
||||
io.print(
|
||||
OPS_MENU.map(([key, label]) => `${key.padStart(2)}. ${label}`).join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const choice = (await io.ask('번호를 고르세요: ')).trim();
|
||||
if (choice === '0') {
|
||||
io.print('끝냅니다.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.dispatch(io, operator, choice);
|
||||
} catch (error) {
|
||||
if (error instanceof OpsInputClosed) throw error;
|
||||
io.print(failureLine(error));
|
||||
if (error instanceof OpsEngineError && error.kind === 'not-signed-in') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(
|
||||
io: OpsIo,
|
||||
operator: string,
|
||||
choice: string,
|
||||
): Promise<void> {
|
||||
switch (choice) {
|
||||
case '1':
|
||||
return this.accounts.hire(io, operator);
|
||||
case '2':
|
||||
return this.accounts.resetPassword(io, operator);
|
||||
case '3':
|
||||
return this.accounts.resetTwoFactor(io, operator);
|
||||
case '4':
|
||||
return this.locks.lock(io, operator);
|
||||
case '5':
|
||||
return this.locks.unlock(io, operator);
|
||||
case '6':
|
||||
return this.locks.offboard(io, operator);
|
||||
case '7':
|
||||
return this.admin.revokeLink(io, operator);
|
||||
case '8':
|
||||
return this.admin.mailingLists(io, operator);
|
||||
case '9':
|
||||
return this.admin.status(io);
|
||||
case '10':
|
||||
return this.admin.policy(io, operator);
|
||||
default:
|
||||
io.print('메뉴의 번호를 넣어 주세요.');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { type DataSource, type EntityManager, In } from 'typeorm';
|
||||
|
||||
import { AppPasswordUsageEntity } from '@/modules/account-security/app-password-usage.entity';
|
||||
import { LoginEventEntity } from '@/modules/account-security/login-event.entity';
|
||||
import { TwoFactorSettingEntity } from '@/modules/account-security/two-factor-setting.entity';
|
||||
import { AuthSessionRepository } from '@/modules/auth/auth-session.repository';
|
||||
import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity';
|
||||
import {
|
||||
FILE_BLOB_STORE,
|
||||
type FileBlobStore,
|
||||
} from '@/modules/file-storage/file-blob-store';
|
||||
import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity';
|
||||
import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity';
|
||||
import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity';
|
||||
import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity';
|
||||
import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage';
|
||||
import { MailUserSettingsEntity } from '@/modules/mail-user-settings/mail-user-settings.entity';
|
||||
import {
|
||||
SYSTEM_SETTINGS_ROW_ID,
|
||||
SystemSettingsEntity,
|
||||
} from '@/modules/system-settings/system-settings.entity';
|
||||
|
||||
/**
|
||||
* 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인).
|
||||
* 웹메일 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절).
|
||||
* 끊긴 행은 로그인된 브라우저 목록에 보이지 않고 쓸 수도 없다
|
||||
*/
|
||||
export interface AddressRows {
|
||||
readonly loginEvents: number;
|
||||
readonly appPasswordUsages: number;
|
||||
readonly twoFactorSettings: number;
|
||||
readonly signatures: number;
|
||||
readonly signatureImages: number;
|
||||
readonly mailSettings: number;
|
||||
readonly directory: number;
|
||||
readonly files: number;
|
||||
readonly shareLinks: number;
|
||||
}
|
||||
|
||||
/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */
|
||||
export interface AddressPurge {
|
||||
readonly rows: AddressRows;
|
||||
readonly exemptRemoved: boolean;
|
||||
readonly sessionsRevoked: number;
|
||||
readonly filesLeftForCleanup: number;
|
||||
}
|
||||
|
||||
export function totalRows(rows: AddressRows): number {
|
||||
return (Object.values(rows) as number[]).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 예외 주소 목록에서 그 주소를 뺀 것 (대소문자 · 앞뒤 공백 무시 — 서버 설정 읽기와 같은 규칙) */
|
||||
export function exemptWithout(
|
||||
exempt: unknown,
|
||||
email: string,
|
||||
): { exempt: string[]; removed: boolean } {
|
||||
const entries = Array.isArray(exempt)
|
||||
? exempt.filter((entry): entry is string => typeof entry === 'string')
|
||||
: [];
|
||||
const kept = entries.filter(
|
||||
(entry) => entry.trim().toLowerCase() !== email.trim().toLowerCase(),
|
||||
);
|
||||
return { exempt: kept, removed: kept.length !== entries.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리.
|
||||
* 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로
|
||||
* 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpsDataService {
|
||||
private readonly logger = new Logger(OpsDataService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
@Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore,
|
||||
private readonly signatureImages: SignatureImageStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 그 직원의 웹메일 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts)
|
||||
*/
|
||||
revokeSessions(email: string): Promise<number> {
|
||||
return this.sessions.revokeAllOf(
|
||||
email,
|
||||
'ended-by-operator',
|
||||
new Date(),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
/** 웹메일에서 2단계 인증을 켠 때 기록을 지운다 (2단계 해제 — 엔진이 기준이고 이 기록은 켠 때뿐이다) */
|
||||
async forgetTwoFactor(email: string): Promise<void> {
|
||||
await this.dataSource.manager.delete(TwoFactorSettingEntity, {
|
||||
userEmail: email,
|
||||
});
|
||||
}
|
||||
|
||||
/** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */
|
||||
async lastWebmailLogins(
|
||||
emails: readonly string[],
|
||||
): Promise<Map<string, Date>> {
|
||||
if (emails.length === 0) return new Map();
|
||||
const rows = await this.dataSource.manager
|
||||
.createQueryBuilder(LoginEventEntity, 'event')
|
||||
.select('event.user_email', 'email')
|
||||
.addSelect('MAX(event.occurred_at)', 'last')
|
||||
.where({ userEmail: In([...emails]), method: 'webmail', isSuccess: true })
|
||||
.groupBy('event.user_email')
|
||||
.getRawMany<{ email: string; last: Date | string }>();
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.email,
|
||||
row.last instanceof Date ? row.last : new Date(row.last),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 주소로 남은 줄 수 */
|
||||
async countRowsOf(email: string): Promise<AddressRows> {
|
||||
return this.rowsOf(this.dataSource.manager, email, 'count');
|
||||
}
|
||||
|
||||
/** 그 주소가 2단계 인증 필수의 예외 주소 목록에 있는가 (서버 설정 표 — 기억 없이 읽는다) */
|
||||
async isTwoFactorExempt(email: string): Promise<boolean> {
|
||||
const row = await this.dataSource.manager.findOneBy(SystemSettingsEntity, {
|
||||
id: SYSTEM_SETTINGS_ROW_ID,
|
||||
});
|
||||
return exemptWithout(row?.twoFactorExempt, email).removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다
|
||||
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기).
|
||||
* 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다
|
||||
*/
|
||||
async purgeAddress(email: string): Promise<AddressPurge> {
|
||||
// 디스크에서 지울 파일 — 줄을 지우기 전에 이름을 읽어 둔다
|
||||
const fileIds = (
|
||||
await this.dataSource.manager.find(StoredFileEntity, {
|
||||
select: { id: true },
|
||||
where: { ownerEmail: email },
|
||||
})
|
||||
).map((file) => file.id);
|
||||
const imageNames = (
|
||||
await this.dataSource.manager.find(MailSignatureImageEntity, {
|
||||
select: { fileName: true },
|
||||
where: { userEmail: email },
|
||||
})
|
||||
).map((image) => image.fileName);
|
||||
|
||||
// 살아 있는 세션은 끊는다 — 행은 지우지 않는다: 끊은 기록을 서버(다른 프로세스)가 15초 동기화로 읽어 남은 액세스 토큰을 막는다(계약 6절)
|
||||
const sessionsRevoked = await this.revokeSessions(email);
|
||||
const now = new Date();
|
||||
const { rows, exemptRemoved } = await this.dataSource.transaction(
|
||||
async (manager) => ({
|
||||
rows: await this.rowsOf(manager, email, 'delete'),
|
||||
exemptRemoved: await this.removeExempt(manager, email, now),
|
||||
}),
|
||||
);
|
||||
|
||||
let filesLeftForCleanup = 0;
|
||||
for (const id of fileIds) {
|
||||
try {
|
||||
await this.blobs.remove(id);
|
||||
} catch (error) {
|
||||
filesLeftForCleanup += 1;
|
||||
this.logger.warn(
|
||||
`[SYS_001] 퇴사 마무리 — 대용량 파일을 디스크에서 지우지 못해 정리 작업에 맡깁니다: id=${id}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const name of imageNames) {
|
||||
try {
|
||||
await this.signatureImages.remove(name);
|
||||
} catch (error) {
|
||||
filesLeftForCleanup += 1;
|
||||
this.logger.warn(
|
||||
`[SYS_001] 퇴사 마무리 — 서명 그림을 디스크에서 지우지 못해 정리 작업에 맡깁니다: reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { rows, exemptRemoved, sessionsRevoked, filesLeftForCleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 필수의 예외 주소에 넣거나 뺀다 — 서버 설정 줄을 잠그고 읽고 쓴다(운영 도구 둘이 함께 바꿔도 잃지 않게 · 퇴사 마무리와 같은 잠금) →
|
||||
* 바꿨으면 true, 이미 그 상태면 false (재리뷰 N5)
|
||||
*/
|
||||
async changeTwoFactorExempt(
|
||||
email: string,
|
||||
include: boolean,
|
||||
): Promise<boolean> {
|
||||
const now = new Date();
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
if (!include) return this.removeExempt(manager, email, now);
|
||||
const row = await manager.findOne(SystemSettingsEntity, {
|
||||
where: { id: SYSTEM_SETTINGS_ROW_ID },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!row) {
|
||||
throw new Error(
|
||||
'system_settings 줄이 없습니다 — 마이그레이션을 확인하세요',
|
||||
);
|
||||
}
|
||||
const { exempt, removed: present } = exemptWithout(
|
||||
row.twoFactorExempt,
|
||||
email,
|
||||
);
|
||||
if (present) return false;
|
||||
await manager.update(
|
||||
SystemSettingsEntity,
|
||||
{ id: SYSTEM_SETTINGS_ROW_ID },
|
||||
{ twoFactorExempt: [...exempt, email], updatedAt: now },
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 2단계 인증 필수의 예외 주소에서 뺀다 → 뺐으면 true (서버 설정 줄을 잠그고 — 운영 도구 둘이 함께 바꿔도 잃지 않게) */
|
||||
private async removeExempt(
|
||||
manager: EntityManager,
|
||||
email: string,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const row = await manager.findOne(SystemSettingsEntity, {
|
||||
where: { id: SYSTEM_SETTINGS_ROW_ID },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!row) return false;
|
||||
const { exempt, removed } = exemptWithout(row.twoFactorExempt, email);
|
||||
if (!removed) return false;
|
||||
await manager.update(
|
||||
SystemSettingsEntity,
|
||||
{ id: SYSTEM_SETTINGS_ROW_ID },
|
||||
{ twoFactorExempt: exempt, updatedAt: now },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 표마다 세거나 지운다 — 같은 차례 · 같은 조건 */
|
||||
private async rowsOf(
|
||||
manager: EntityManager,
|
||||
email: string,
|
||||
mode: 'count' | 'delete',
|
||||
): Promise<AddressRows> {
|
||||
const run = async (
|
||||
entity:
|
||||
| typeof LoginEventEntity
|
||||
| typeof AppPasswordUsageEntity
|
||||
| typeof TwoFactorSettingEntity
|
||||
| typeof MailSignatureEntity
|
||||
| typeof MailSignatureImageEntity
|
||||
| typeof MailUserSettingsEntity
|
||||
| typeof MailDirectoryEntryEntity
|
||||
| typeof StoredFileEntity
|
||||
| typeof ShareLinkEntity,
|
||||
where: Record<string, string>,
|
||||
): Promise<number> => {
|
||||
if (mode === 'count') return manager.countBy(entity, where);
|
||||
return (await manager.delete(entity, where)).affected ?? 0;
|
||||
};
|
||||
return {
|
||||
loginEvents: await run(LoginEventEntity, { userEmail: email }),
|
||||
appPasswordUsages: await run(AppPasswordUsageEntity, {
|
||||
userEmail: email,
|
||||
}),
|
||||
twoFactorSettings: await run(TwoFactorSettingEntity, {
|
||||
userEmail: email,
|
||||
}),
|
||||
signatures: await run(MailSignatureEntity, { userEmail: email }),
|
||||
signatureImages: await run(MailSignatureImageEntity, {
|
||||
userEmail: email,
|
||||
}),
|
||||
mailSettings: await run(MailUserSettingsEntity, { userEmail: email }),
|
||||
directory: await run(MailDirectoryEntryEntity, { email }),
|
||||
// 조각 줄은 외래 키(CASCADE)로 함께 지워진다
|
||||
files: await run(StoredFileEntity, { ownerEmail: email }),
|
||||
// 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다
|
||||
shareLinks: await run(ShareLinkEntity, { ownerEmail: email }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 운영 도구의 엔진 포트 (13단계 — 계약 6절 "운영 도구") — 명령 로직은 이 인터페이스만 안다.
|
||||
* 지금 구현은 Stalwart — `modules/mail/jmap/jmap-ops-engine.ts`.
|
||||
* - 관리자 Basic(실행할 때 받은 비밀번호 — 저장하지 않는다)은 계정 관리 API(`x:Account` · `x:MailingList` · 계정을 만들 도메인)에만 쓴다
|
||||
* - 직원의 앱 비밀번호 · Sieve(받는 메일 넘기기 · 자동 응답)는 웹메일과 같은 게이트웨이 대리 접근으로 한다 — 관리자 계정에서는 대리 접근 권한(impersonate)을 뺀다(인프라)
|
||||
* 실패는 BusinessException 으로 던진다 — 엔진의 거절은 context 에 종류(jmapType)만 싣는다(설명 문장 · 비밀번호는 넣지 않는다)
|
||||
*/
|
||||
export const OPS_ENGINE = Symbol('OPS_ENGINE');
|
||||
|
||||
/** 관리자 로그인 결과 */
|
||||
export type OpsSignInResult = 'ok' | 'rejected' | 'two-factor' | 'not-admin';
|
||||
|
||||
/** 엔진의 직원 계정 하나 (현황 · 명령의 대상) */
|
||||
export interface EngineEmployee {
|
||||
/** 엔진의 계정 id — 불투명 */
|
||||
readonly id: string;
|
||||
/** 주소 (소문자) */
|
||||
readonly email: string;
|
||||
/** 표시 이름 — 없으면 주소의 @ 앞 */
|
||||
readonly name: string;
|
||||
/** 잠김 — 로그인 권한(authenticate)을 뺐다 */
|
||||
readonly locked: boolean;
|
||||
/** 계정 권한이 직접 정해진(Replace) 계정 — 잠그고 푸는 것을 이 도구가 하지 않는다 */
|
||||
readonly customPermissions: boolean;
|
||||
/** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다) */
|
||||
readonly passwordExpired: boolean;
|
||||
/** 2단계 인증(OTP)이 켜져 있다 */
|
||||
readonly twoFactorEnabled: boolean;
|
||||
/** 앱 비밀번호 수 */
|
||||
readonly appPasswords: number;
|
||||
/** 메일함 사용량 (바이트) — 모르면 null */
|
||||
readonly usedBytes: number | null;
|
||||
/** 메일함 용량 한도 (바이트) — 없으면 null(무제한) */
|
||||
readonly quotaBytes: number | null;
|
||||
}
|
||||
|
||||
/** 공용 주소(MailingList) — 구성원 받은편지함으로 복사한다 */
|
||||
export interface EngineMailingList {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
/** 구성원 주소 (소문자) */
|
||||
readonly members: readonly string[];
|
||||
}
|
||||
|
||||
/** 잠글 때 거는 Sieve — 받는 메일 넘기기(사본) · 자동 응답 */
|
||||
export interface LockRules {
|
||||
/** 사본을 받을 사내 주소 — 넘기지 않으면 null */
|
||||
readonly forwardTo: string | null;
|
||||
/** 자동 응답 — 걸지 않으면 null */
|
||||
readonly autoReply: {
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** 지금 걸린 잠금 Sieve */
|
||||
export interface LockRulesState {
|
||||
readonly forwardTo: string | null;
|
||||
readonly autoReply: boolean;
|
||||
}
|
||||
|
||||
export interface OpsEngine {
|
||||
/** 관리자 로그인 — 맞으면 이 프로세스가 끝날 때까지 메모리에만 둔다 */
|
||||
signIn(adminPassword: string): Promise<OpsSignInResult>;
|
||||
|
||||
/**
|
||||
* 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다).
|
||||
* 아니면 명령은 "직원 주소가 아님"으로 멈춘다 — "엔진에 없음"과 가른다(없음은 기록만 남은 주소의 정리로 이어진다)
|
||||
*/
|
||||
isEmployeeAddress(email: string): boolean;
|
||||
/** 주소로 직원 계정 찾기 (직원 주소가 아니거나 · User 가 아닌 계정(Group)이거나 · 없으면 null) */
|
||||
findEmployee(email: string): Promise<EngineEmployee | null>;
|
||||
/** 직원 계정 모두 (주소 차례 — User 계정 가운데 직원 주소만) */
|
||||
listEmployees(): Promise<EngineEmployee[]>;
|
||||
/** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */
|
||||
addressInUse(email: string): Promise<boolean>;
|
||||
|
||||
/** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */
|
||||
createEmployee(input: {
|
||||
readonly localPart: string;
|
||||
readonly name: string;
|
||||
readonly quotaBytes: number | null;
|
||||
readonly tempPassword: string;
|
||||
}): Promise<EngineEmployee>;
|
||||
/** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */
|
||||
resetPassword(id: string, tempPassword: string): Promise<void>;
|
||||
/** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */
|
||||
removeTwoFactor(id: string): Promise<void>;
|
||||
/** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */
|
||||
setLoginEnabled(id: string, enabled: boolean): Promise<void>;
|
||||
/** 계정 지우기 — 그 계정의 메일이 영구 삭제된다 */
|
||||
deleteAccount(id: string): Promise<void>;
|
||||
|
||||
listMailingLists(): Promise<EngineMailingList[]>;
|
||||
createMailingList(input: {
|
||||
readonly localPart: string;
|
||||
readonly name: string;
|
||||
readonly members: readonly string[];
|
||||
}): Promise<EngineMailingList>;
|
||||
setMailingListMember(
|
||||
id: string,
|
||||
member: string,
|
||||
included: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
/** 앱 비밀번호를 모두 지운다 → 지운 수 (대리 접근 — 잠겼거나 비밀번호가 만료된 계정은 엔진이 막는다) */
|
||||
deleteAppPasswords(email: string): Promise<number>;
|
||||
/** 지금 걸린 잠금 Sieve (대리 접근) */
|
||||
getLockRules(email: string): Promise<LockRulesState>;
|
||||
/** 잠금 Sieve 를 건다 — 직원의 필터 · 부재중 · 차단과 한 스크립트(엔진은 스크립트 하나만 켠다) */
|
||||
applyLockRules(email: string, rules: LockRules): Promise<void>;
|
||||
/** 잠금 Sieve 를 뗀다 — 넘기기 규칙을 빼고 자동 응답을 끈다 */
|
||||
clearLockRules(email: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** 관리자 계정 주소 — `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (운영 모듈이 설정에서 만든다) */
|
||||
export const OPS_ADMIN_ACCOUNT = Symbol('OPS_ADMIN_ACCOUNT');
|
||||
|
||||
/** 잠글 때 넣는 넘기기 규칙의 이름 — 풀 때 이 이름의 규칙을 찾아 뗀다 (필터 이름 50자 안) */
|
||||
export const LOCK_FORWARD_RULE_NAME = '잠금 — 받는 메일 넘기기 (운영 도구)';
|
||||
|
||||
/** 엔진이 받지 않은 까닭 — 명령이 한국어로 알린다 */
|
||||
export type OpsEngineErrorKind =
|
||||
/** 엔진이 임시 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */
|
||||
| 'weak-password'
|
||||
/** 같은 이름(주소)이 이미 있다 */
|
||||
| 'exists'
|
||||
/** 대상이 없다 */
|
||||
| 'not-found'
|
||||
/** 계정 권한이 직접 정해져 이 도구가 바꾸지 않는다 */
|
||||
| 'custom-permissions'
|
||||
/** 필터가 이미 가득하다(50개) — 넘기기 규칙을 더할 수 없다 */
|
||||
| 'too-many-rules'
|
||||
/** 관리자로 들어가지 않았다 · 관리자 비밀번호가 그사이 바뀌었다 */
|
||||
| 'not-signed-in'
|
||||
/** 그 밖의 거절 */
|
||||
| 'refused'
|
||||
/** 엔진에 닿지 못함 · 응답을 읽지 못함 */
|
||||
| 'unavailable';
|
||||
|
||||
/** 엔진의 거절 — detail 은 엔진 오류의 종류(type)만(설명 문장 · 비밀번호는 넣지 않는다) */
|
||||
export class OpsEngineError extends Error {
|
||||
constructor(
|
||||
readonly kind: OpsEngineErrorKind,
|
||||
readonly detail: string | null = null,
|
||||
) {
|
||||
super(`ops-engine:${kind}${detail ? `/${detail}` : ''}`);
|
||||
this.name = 'OpsEngineError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { OpsEngineError } from './ops-engine.port';
|
||||
import { OpsInputClosed, TerminalIo, isYes } from './ops-io';
|
||||
import {
|
||||
failureDetail,
|
||||
failureText,
|
||||
toAddress,
|
||||
toDisplayName,
|
||||
toQuotaBytes,
|
||||
} from './ops-text';
|
||||
import { TEMP_PASSWORD_LENGTH, generateTempPassword } from './temp-password';
|
||||
|
||||
describe('임시 비밀번호 (13단계)', () => {
|
||||
it('16자 · 헷갈리는 글자 없음 · 대문자 · 소문자 · 숫자가 하나씩은 있다', () => {
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const password = generateTempPassword();
|
||||
expect(password).toHaveLength(TEMP_PASSWORD_LENGTH);
|
||||
expect(password).toMatch(/^[A-HJ-NP-Za-km-np-z2-9]+$/);
|
||||
expect(password).toMatch(/[A-Z]/);
|
||||
expect(password).toMatch(/[a-z]/);
|
||||
expect(password).toMatch(/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it('세 가지가 섞이지 않은 뽑기는 버리고 다시 뽑는다', () => {
|
||||
// 처음 16번은 늘 첫 글자(A) — 대문자뿐이라 버려진다. 그다음은 차례로 돈다
|
||||
let calls = 0;
|
||||
const pick = (max: number) =>
|
||||
calls++ < TEMP_PASSWORD_LENGTH ? 0 : calls % max;
|
||||
const password = generateTempPassword(pick);
|
||||
expect(calls).toBeGreaterThan(TEMP_PASSWORD_LENGTH);
|
||||
expect(password).toMatch(/[a-z]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('운영 도구의 값 읽기 · 글', () => {
|
||||
it('주소 — @도메인 을 붙이고 소문자로, 다른 도메인 · 모양이 틀린 것은 null', () => {
|
||||
expect(toAddress('Hong.Gildong', 'arasystem.test')).toBe(
|
||||
'hong.gildong@arasystem.test',
|
||||
);
|
||||
expect(toAddress(' ara.kim@ARASYSTEM.test ', 'arasystem.test')).toBe(
|
||||
'ara.kim@arasystem.test',
|
||||
);
|
||||
for (const input of [
|
||||
'',
|
||||
'a@example.com',
|
||||
'.hong',
|
||||
'hong.',
|
||||
'ho..ng',
|
||||
'hong gildong',
|
||||
'홍길동',
|
||||
'a'.repeat(65),
|
||||
'a@b@arasystem.test',
|
||||
]) {
|
||||
expect(toAddress(input, 'arasystem.test')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('이름 — 한 줄 1~100자', () => {
|
||||
expect(toDisplayName(' 홍길동 ')).toBe('홍길동');
|
||||
expect(toDisplayName('')).toBeNull();
|
||||
expect(toDisplayName('가'.repeat(101))).toBeNull();
|
||||
expect(toDisplayName('홍\n길동')).toBeNull();
|
||||
});
|
||||
|
||||
it('메일함 용량(GB) — 빈 값은 무제한, 소수 한 자리까지, 0 · 음수 · 너무 큰 값은 invalid', () => {
|
||||
expect(toQuotaBytes('')).toBeNull();
|
||||
expect(toQuotaBytes('1')).toBe(1024 ** 3);
|
||||
expect(toQuotaBytes('2.5')).toBe(2.5 * 1024 ** 3);
|
||||
for (const input of ['0', '-1', '1.25', '1e3', 'abc', '10001']) {
|
||||
expect(toQuotaBytes(input)).toBe('invalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('실패의 글 — 엔진의 까닭 · 코드만(설명 문장 없이)', () => {
|
||||
expect(failureText(new OpsEngineError('exists'))).toContain(
|
||||
'이미 있습니다',
|
||||
);
|
||||
expect(failureText(new OpsEngineError('not-signed-in'))).toContain(
|
||||
'도구를 다시 시작하세요',
|
||||
);
|
||||
expect(failureText(new OpsEngineError('refused', 'overQuota'))).toContain(
|
||||
'overQuota',
|
||||
);
|
||||
expect(failureText(new BusinessException('MAIL_001'))).toContain(
|
||||
'메일 엔진',
|
||||
);
|
||||
expect(
|
||||
failureDetail(
|
||||
new BusinessException('MAIL_001', { context: { reason: 'timeout' } }),
|
||||
),
|
||||
).toBe('MAIL_001/timeout');
|
||||
expect(failureDetail(new OpsEngineError('refused', 'forbidden'))).toBe(
|
||||
'engine:refused/forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('y/N — y · yes · 예 · ㅇ 만 예', () => {
|
||||
for (const yes of ['y', 'Y', 'yes', '예', 'ㅇ', ' y ']) {
|
||||
expect(isYes(yes)).toBe(true);
|
||||
}
|
||||
for (const no of ['', 'n', 'no', '아니오', 'yy']) {
|
||||
expect(isYes(no)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TerminalIo — 터미널 입출력', () => {
|
||||
function setup() {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
let written = '';
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
written += chunk.toString('utf8');
|
||||
});
|
||||
const io = new TerminalIo(input, output);
|
||||
return { input, io, written: () => written };
|
||||
}
|
||||
|
||||
it('한꺼번에 들어온 줄(붙여 넣기 · 파이프)도 잃지 않고 차례로 꺼낸다', async () => {
|
||||
const { input, io } = setup();
|
||||
input.write('운영자\n비밀\n9\n');
|
||||
await expect(io.ask('이름: ')).resolves.toBe('운영자');
|
||||
await expect(io.askSecret('비밀번호: ')).resolves.toBe('비밀');
|
||||
await expect(io.ask('번호: ')).resolves.toBe('9');
|
||||
io.close();
|
||||
});
|
||||
|
||||
it('비밀은 앞뒤 공백도 그대로 · y/N 은 y 일 때만 예', async () => {
|
||||
const { input, io } = setup();
|
||||
const secret = io.askSecret('비밀번호: ');
|
||||
input.write(' pass word \n');
|
||||
await expect(secret).resolves.toBe(' pass word ');
|
||||
const yes = io.confirm('할까요?');
|
||||
input.write('y\n');
|
||||
await expect(yes).resolves.toBe(true);
|
||||
const no = io.confirm('할까요?');
|
||||
input.write('\n');
|
||||
await expect(no).resolves.toBe(false);
|
||||
io.close();
|
||||
});
|
||||
|
||||
it('입력이 끝나면 기다리던 물음과 다음 물음이 OpsInputClosed 로 끝난다', async () => {
|
||||
const { input, io } = setup();
|
||||
const waiting = io.ask('이름: ');
|
||||
input.end();
|
||||
await expect(waiting).rejects.toBeInstanceOf(OpsInputClosed);
|
||||
await expect(io.ask('또: ')).rejects.toBeInstanceOf(OpsInputClosed);
|
||||
});
|
||||
|
||||
it('보이는 줄과 물음은 출력에 쓴다', async () => {
|
||||
const { input, io, written } = setup();
|
||||
io.print('안녕');
|
||||
const answer = io.ask('이름: ');
|
||||
input.write('a\n');
|
||||
await answer;
|
||||
expect(written()).toContain('안녕\n');
|
||||
expect(written()).toContain('이름: ');
|
||||
io.close();
|
||||
});
|
||||
|
||||
it('터미널이 아니면 interactive 가 아니다 — 자동 시험만 allowNonInteractive(OPS_ALLOW_NO_TTY=1)로 연다', () => {
|
||||
const { io } = setup();
|
||||
expect(io.interactive).toBe(false);
|
||||
io.close();
|
||||
|
||||
const piped = new TerminalIo(new PassThrough(), new PassThrough(), {
|
||||
allowNonInteractive: true,
|
||||
});
|
||||
expect(piped.interactive).toBe(true);
|
||||
piped.close();
|
||||
|
||||
const terminal = Object.assign(new PassThrough(), { isTTY: true });
|
||||
const tty = new TerminalIo(terminal, new PassThrough());
|
||||
expect(tty.interactive).toBe(true);
|
||||
tty.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TerminalIo — 터미널(TTY)에서 비밀번호 (리뷰 H1)', () => {
|
||||
const SECRET = 'S3cretAdminPw';
|
||||
|
||||
function terminal() {
|
||||
const input = Object.assign(new PassThrough(), { isTTY: true });
|
||||
const output = new PassThrough();
|
||||
let written = '';
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
written += chunk.toString('utf8');
|
||||
});
|
||||
const io = new TerminalIo(input, output);
|
||||
return { input, io, written: () => written };
|
||||
}
|
||||
|
||||
it('친 비밀번호는 화면에 되비치지 않고, 입력 기록에도 남지 않아 ↑ 로 다시 그려지지 않는다', async () => {
|
||||
const { input, io, written } = terminal();
|
||||
const secret = io.askSecret('비밀번호: ');
|
||||
input.write(`${SECRET}\r`);
|
||||
await expect(secret).resolves.toBe(SECRET);
|
||||
expect(written()).not.toContain(SECRET);
|
||||
|
||||
// ↑ 다음에 친 줄 — 기록이 있었다면 ↑ 가 비밀번호를 불러와 그 뒤에 9 가 붙고 화면에도 그려졌다
|
||||
const next = io.ask('번호: ');
|
||||
input.write('\x1b[A');
|
||||
input.write('9\r');
|
||||
await expect(next).resolves.toBe('9');
|
||||
expect(written()).not.toContain(SECRET);
|
||||
io.close();
|
||||
});
|
||||
|
||||
it('보통 줄도 기록에 남기지 않는다 — ↑ 는 아무것도 불러오지 않는다', async () => {
|
||||
const { input, io } = terminal();
|
||||
const first = io.ask('번호: ');
|
||||
input.write('10\r');
|
||||
await expect(first).resolves.toBe('10');
|
||||
const second = io.ask('번호: ');
|
||||
input.write('\x1b[A');
|
||||
input.write('\r');
|
||||
await expect(second).resolves.toBe('');
|
||||
io.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createInterface, type Interface } from 'readline';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
/**
|
||||
* 운영 도구의 묻기 · 보이기 (13단계) — 명령 로직은 이 인터페이스만 안다(시험은 흉내를 넣는다).
|
||||
* 화면에 쓰는 것은 이것뿐이다 — 비밀(관리자 비밀번호)은 askSecret 으로 받아 화면에 보이지 않는다.
|
||||
* 임시 비밀번호는 print 로 한 번만 보이고 어디에도 남기지 않는다
|
||||
*/
|
||||
export interface OpsIo {
|
||||
/**
|
||||
* 사람이 터미널에서 돌리는가 — 표준 입력이 TTY 일 때만 true. 아니면 도구는 비밀번호를 묻기 전에 멈춘다
|
||||
* (파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다). 자동 시험만 `OPS_ALLOW_NO_TTY=1` 로 연다
|
||||
*/
|
||||
readonly interactive: boolean;
|
||||
/** 터미널이 아닌 입력을 시험용 변수(`OPS_ALLOW_NO_TTY=1`)로 받는 중인가 — 도구가 첫 화면에 경고 한 줄을 보인다 */
|
||||
readonly pipedForTesting?: boolean;
|
||||
/** 한 줄 보이기 (빈 값이면 빈 줄) */
|
||||
print(line?: string): void;
|
||||
/** 한 줄 묻기 — 앞뒤 공백을 지운 값 */
|
||||
ask(question: string): Promise<string>;
|
||||
/** 화면에 보이지 않게 묻기 (관리자 비밀번호) — 지우지 않은 그대로 */
|
||||
askSecret(question: string): Promise<string>;
|
||||
/** y/N — y · yes · 예 · ㅇ 만 예, 그 밖(빈 값 포함)은 아니오 */
|
||||
confirm(question: string): Promise<boolean>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** 입력이 끝났다(Ctrl+D · Ctrl+C · 파이프 끝) — 도구를 끝낸다 */
|
||||
export class OpsInputClosed extends Error {
|
||||
constructor() {
|
||||
super('입력이 끝났습니다.');
|
||||
this.name = 'OpsInputClosed';
|
||||
}
|
||||
}
|
||||
|
||||
const YES = new Set(['y', 'yes', '예', 'ㅇ']);
|
||||
|
||||
/** y/N 답 → 예인가 */
|
||||
export function isYes(answer: string): boolean {
|
||||
return YES.has(answer.trim().toLowerCase());
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
readonly resolve: (line: string) => void;
|
||||
readonly reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 터미널이 아닌 표준 입력(파이프)도 받게 하는 환경 변수 — 값이 `1` 일 때만. 자동 시험(`docker exec -i -e OPS_ALLOW_NO_TTY=1 …`)용이고 사람이 쓰지 않는다
|
||||
*/
|
||||
export const OPS_ALLOW_NO_TTY_ENV = 'OPS_ALLOW_NO_TTY';
|
||||
|
||||
/** 터미널이 아닌 입력(파이프)도 받을지 — `OPS_ALLOW_NO_TTY=1` 일 때만 */
|
||||
export interface TerminalIoOptions {
|
||||
readonly allowNonInteractive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 터미널 입출력 — `docker exec -it <backend> node dist/cli/ops.js`.
|
||||
* - 들어온 줄은 차례로 쌓아 두고 물을 때 하나씩 꺼낸다 — 붙여 넣거나 파이프로 한꺼번에 들어온 줄도 잃지 않는다
|
||||
* - 비밀은 출력 흐름을 잠깐 막아(muted) 친 글자가 화면에 되비치지 않게 받는다
|
||||
* - **입력 기록(history)을 두지 않는다**(`historySize: 0`) — 두면 비밀번호 줄이 기록에 남아 ↑ 한 번에 화면에 평문으로 다시 그려진다
|
||||
* (2026-11-02 리뷰). 메뉴 번호 · 주소를 다시 부를 일은 없다
|
||||
* - console 은 쓰지 않는다(린트 규칙) — 표준 출력에 직접 쓴다
|
||||
*/
|
||||
export class TerminalIo implements OpsIo {
|
||||
readonly interactive: boolean;
|
||||
readonly pipedForTesting: boolean;
|
||||
private muted = false;
|
||||
private closed = false;
|
||||
private readonly rl: Interface;
|
||||
private readonly lines: string[] = [];
|
||||
private readonly waiters: Waiter[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly input: NodeJS.ReadableStream = process.stdin,
|
||||
private readonly output: NodeJS.WritableStream = process.stdout,
|
||||
options: TerminalIoOptions = {},
|
||||
) {
|
||||
const isTerminal = (this.input as { isTTY?: boolean }).isTTY === true;
|
||||
this.interactive = isTerminal || options.allowNonInteractive === true;
|
||||
this.pipedForTesting = !isTerminal && options.allowNonInteractive === true;
|
||||
// 터미널이 되비치는 글자(친 글자)만 이 흐름을 지난다 — 비밀을 묻는 동안은 버린다
|
||||
const echo = new Writable({
|
||||
write: (chunk: Buffer | string, _encoding, callback) => {
|
||||
if (!this.muted) this.output.write(chunk);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
this.rl = createInterface({
|
||||
input: this.input,
|
||||
output: echo,
|
||||
terminal: isTerminal,
|
||||
// 입력 기록 없음 — 비밀번호 줄이 ↑ 로 다시 그려지지 않게 (위 머리말)
|
||||
historySize: 0,
|
||||
});
|
||||
this.rl.setPrompt('');
|
||||
this.rl.on('line', (line: string) => {
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) waiter.resolve(line);
|
||||
else this.lines.push(line);
|
||||
});
|
||||
this.rl.on('close', () => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter.reject(new OpsInputClosed());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
print(line = ''): void {
|
||||
this.output.write(`${line}\n`);
|
||||
}
|
||||
|
||||
async ask(question: string): Promise<string> {
|
||||
this.output.write(question);
|
||||
return (await this.nextLine()).trim();
|
||||
}
|
||||
|
||||
async askSecret(question: string): Promise<string> {
|
||||
this.output.write(question);
|
||||
this.muted = true;
|
||||
try {
|
||||
return await this.nextLine();
|
||||
} finally {
|
||||
this.muted = false;
|
||||
this.output.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(question: string): Promise<boolean> {
|
||||
this.output.write(`${question} (y/N) `);
|
||||
return isYes(await this.nextLine());
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.rl.close();
|
||||
}
|
||||
|
||||
private nextLine(): Promise<string> {
|
||||
const queued = this.lines.shift();
|
||||
if (queued !== undefined) return Promise.resolve(queued);
|
||||
if (this.closed) return Promise.reject(new OpsInputClosed());
|
||||
return new Promise((resolve, reject) => {
|
||||
this.waiters.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import {
|
||||
OpsEngineError,
|
||||
type EngineEmployee,
|
||||
type OpsEngine,
|
||||
} from './ops-engine.port';
|
||||
import type { OpsIo } from './ops-io';
|
||||
import { failureDetail, failureText, toAddress } from './ops-text';
|
||||
|
||||
/** 운영 도구의 공통 묻기 · 보이기 — 명령들이 함께 쓴다 */
|
||||
|
||||
const logger = new Logger('OpsTool');
|
||||
|
||||
/**
|
||||
* 모르는 오류(엔진 · 업무 오류가 아닌 것 — DB · 프로그램 오류)는 오류 로그에 남긴다 — 이 창의 ERROR 줄.
|
||||
* 운영자에게는 짧은 글만 보이므로 원인을 여기서 본다. 종류와 스택만이다(비밀번호는 오류에 실리지 않는다)
|
||||
*/
|
||||
export function logUnknownFailure(error: unknown): void {
|
||||
if (error instanceof OpsEngineError || error instanceof BusinessException) {
|
||||
return;
|
||||
}
|
||||
logger.error(
|
||||
`[SYS_001] 운영 도구에서 모르는 오류: ${failureDetail(error)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** 실패 → 운영자에게 보일 글 — 모르는 오류는 오류 로그에도 남긴다 (명령은 failureText 대신 이것을 쓴다) */
|
||||
export function failureLine(error: unknown): string {
|
||||
logUnknownFailure(error);
|
||||
return failureText(error);
|
||||
}
|
||||
|
||||
/** 구분선 */
|
||||
export const RULE = '─'.repeat(48);
|
||||
|
||||
/** 주소를 묻는다 — `@도메인` 은 붙이지 않아도 된다. 모양이 틀리면 알리고 null */
|
||||
export async function askAddress(
|
||||
io: OpsIo,
|
||||
domain: string,
|
||||
label: string,
|
||||
): Promise<string | null> {
|
||||
const input = await io.ask(
|
||||
`${label} (예: hong.gildong — @${domain} 는 붙이지 않아도 됩니다): `,
|
||||
);
|
||||
const email = toAddress(input, domain);
|
||||
if (email === null) {
|
||||
io.print(`주소 모양이 맞지 않거나 @${domain} 주소가 아닙니다.`);
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/** 직원 주소가 아닐 때의 안내 (관리자 · 게이트웨이 · 사내 목록 · 막은 계정) */
|
||||
export const NOT_EMPLOYEE_ADDRESS =
|
||||
'직원 주소가 아닙니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정은 이 도구로 다루지 않습니다.';
|
||||
/** 그 주소의 계정 · 목록이 직원 계정이 아닐 때의 안내 (공용 주소 · 공유 메일함) */
|
||||
export const NOT_EMPLOYEE_ACCOUNT =
|
||||
'그 주소는 직원 계정이 아닙니다(공용 주소 · 공유 메일함) — 이 도구로 다루지 않습니다.';
|
||||
|
||||
/**
|
||||
* 직원 주소를 묻고 엔진에서 찾는다 — 직원 주소가 아님 · 직원 계정이 아님(공용 주소 · 공유 메일함) · 엔진에 없음을 갈라 알리고 null
|
||||
*/
|
||||
export async function askEmployee(
|
||||
io: OpsIo,
|
||||
engine: OpsEngine,
|
||||
domain: string,
|
||||
label = '직원 주소',
|
||||
): Promise<EngineEmployee | null> {
|
||||
const email = await askAddress(io, domain, label);
|
||||
if (email === null) return null;
|
||||
if (!engine.isEmployeeAddress(email)) {
|
||||
io.print(NOT_EMPLOYEE_ADDRESS);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const employee = await engine.findEmployee(email);
|
||||
if (employee) return employee;
|
||||
io.print(
|
||||
(await engine.addressInUse(email))
|
||||
? NOT_EMPLOYEE_ACCOUNT
|
||||
: '엔진에 그 직원 계정이 없습니다.',
|
||||
);
|
||||
return null;
|
||||
} catch (error) {
|
||||
io.print(failureLine(error));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 바뀔 것을 보이고 y/N 으로 묻는다 */
|
||||
export async function confirmPlan(
|
||||
io: OpsIo,
|
||||
title: string,
|
||||
lines: readonly string[],
|
||||
): Promise<boolean> {
|
||||
io.print('');
|
||||
io.print(RULE);
|
||||
io.print(` ${title} — 바뀔 것`);
|
||||
for (const line of lines) io.print(` ${line}`);
|
||||
io.print(RULE);
|
||||
const yes = await io.confirm('이대로 할까요?');
|
||||
if (!yes) io.print('하지 않았습니다.');
|
||||
return yes;
|
||||
}
|
||||
|
||||
/** 임시 비밀번호를 한 번만 보인다 — 어디에도 남기지 않는다 */
|
||||
export function showTempPassword(
|
||||
io: OpsIo,
|
||||
email: string,
|
||||
password: string,
|
||||
): void {
|
||||
io.print('');
|
||||
io.print(RULE);
|
||||
io.print(
|
||||
' 임시 비밀번호 — 지금 한 번만 보입니다. 직원에게 직접(말 · 쪽지) 전하세요.',
|
||||
);
|
||||
io.print(` 주소 ${email}`);
|
||||
io.print(` 임시 비밀번호 ${password}`);
|
||||
io.print(' 직원은 첫 로그인 때 새 비밀번호를 정합니다.');
|
||||
io.print(RULE);
|
||||
}
|
||||
|
||||
/** 한 단계의 결과 줄 */
|
||||
export function step(io: OpsIo, ok: boolean, text: string): void {
|
||||
io.print(` ${ok ? '[완료]' : '[못 함]'} ${text}`);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { formatFileSize } from '@/shared/utils/format';
|
||||
import { formatKstDateTime } from '@/shared/utils/kst-date';
|
||||
import { OpsEngineError } from './ops-engine.port';
|
||||
|
||||
/**
|
||||
* 운영 도구의 글 · 값 읽기 (순수 함수) — 운영자가 넣은 값의 검사와 보이는 글.
|
||||
* 운영자는 비개발자일 수 있다 — 한국어로, 무엇이 왜 안 되는지와 다음에 할 일을 짧게
|
||||
*/
|
||||
|
||||
/** 주소의 로컬 부분 — 소문자 · 숫자 · 점 · 밑줄 · 하이픈, 처음과 끝은 소문자 · 숫자, 64자까지 (엔진 계정 이름) */
|
||||
const LOCAL_PART = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
/** 연속 점은 받지 않는다 (메일 주소 규칙) */
|
||||
const DOUBLE_DOT = /\.\./;
|
||||
/** 이름 · 설명 — 한 줄, 1~100자 */
|
||||
export const DISPLAY_NAME_MAX_LENGTH = 100;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
||||
/** 메일함 용량 상한 (GB) — 넣을 수 있는 값의 끝 (오타 막기) */
|
||||
export const QUOTA_MAX_GB = 10_000;
|
||||
const BYTES_PER_GB = 1024 ** 3;
|
||||
|
||||
/**
|
||||
* 운영자가 넣은 주소 → 정식 주소(소문자) — `@도메인` 을 붙이지 않았으면 붙인다. 다른 도메인 · 모양이 틀리면 null
|
||||
*/
|
||||
export function toAddress(input: string, domain: string): string | null {
|
||||
const value = input.trim().toLowerCase();
|
||||
if (value === '') return null;
|
||||
const at = value.indexOf('@');
|
||||
const local = at < 0 ? value : value.slice(0, at);
|
||||
const host = at < 0 ? domain : value.slice(at + 1);
|
||||
if (host !== domain) return null;
|
||||
if (!LOCAL_PART.test(local) || DOUBLE_DOT.test(local)) return null;
|
||||
return `${local}@${domain}`;
|
||||
}
|
||||
|
||||
/** 표시 이름 · 공용 주소 이름 — 앞뒤 공백을 지운 한 줄 1~100자, 아니면 null */
|
||||
export function toDisplayName(input: string): string | null {
|
||||
const value = input.trim();
|
||||
if (value === '' || value.length > DISPLAY_NAME_MAX_LENGTH) return null;
|
||||
if (CONTROL_CHARACTER.test(value)) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 메일함 용량(GB) — 빈 값은 무제한(null). 양의 수(소수 한 자리까지) · 상한 안이 아니면 'invalid' */
|
||||
export function toQuotaBytes(input: string): number | null | 'invalid' {
|
||||
const value = input.trim();
|
||||
if (value === '') return null;
|
||||
if (!/^\d{1,5}(?:\.\d)?$/.test(value)) return 'invalid';
|
||||
const gigabytes = Number(value);
|
||||
if (!(gigabytes > 0) || gigabytes > QUOTA_MAX_GB) return 'invalid';
|
||||
return Math.round(gigabytes * BYTES_PER_GB);
|
||||
}
|
||||
|
||||
/** 크기 표기 — 없으면 "—" */
|
||||
export function bytesText(bytes: number | null): string {
|
||||
return bytes === null ? '—' : formatFileSize(bytes);
|
||||
}
|
||||
|
||||
/** 한국 시각 — 없으면 "—" */
|
||||
export function timeText(at: Date | null | undefined): string {
|
||||
return at ? formatKstDateTime(at) : '—';
|
||||
}
|
||||
|
||||
/** 응답을 받지 못한 엔진 실패의 까닭 — 엔진이 그 일을 했는지 모른다 */
|
||||
const UNKNOWN_OUTCOME_DETAILS: ReadonlySet<string> = new Set([
|
||||
'timeout',
|
||||
'network',
|
||||
'malformed-response',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 엔진이 결과를 알려 주지 않은 실패인가 — 제한 시간 · 연결 끊김 · 응답을 읽지 못함 · 5xx: 엔진이 그 일을 했는지 모른다 (재리뷰 N3).
|
||||
* 명령은 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알리고, 했을 수도 있는 일의 뒷정리(재설정의 알림 메일 지우기 등)를 하지 않는다
|
||||
*/
|
||||
export function isUnknownOutcome(error: unknown): boolean {
|
||||
if (!(error instanceof OpsEngineError) || error.kind !== 'unavailable') {
|
||||
return false;
|
||||
}
|
||||
const detail = error.detail ?? '';
|
||||
return UNKNOWN_OUTCOME_DETAILS.has(detail) || /^http-5\d\d$/.test(detail);
|
||||
}
|
||||
|
||||
/** 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만) */
|
||||
export function failureText(error: unknown): string {
|
||||
if (isUnknownOutcome(error)) {
|
||||
return `메일 엔진의 답을 받지 못해 결과를 모릅니다 (${(error as OpsEngineError).detail ?? '까닭 모름'}) — 같은 명령을 다시 하세요.`;
|
||||
}
|
||||
if (error instanceof OpsEngineError) {
|
||||
switch (error.kind) {
|
||||
case 'weak-password':
|
||||
return '엔진이 임시 비밀번호를 받지 않았습니다(강도). 다시 해 주세요.';
|
||||
case 'exists':
|
||||
return '같은 이름(주소)이 엔진에 이미 있습니다.';
|
||||
case 'not-found':
|
||||
return '엔진에서 대상을 찾지 못했습니다.';
|
||||
case 'custom-permissions':
|
||||
return '이 계정은 권한이 직접 정해져 있어 이 도구로 바꾸지 않습니다 — 엔진 관리 화면에서 하세요.';
|
||||
case 'too-many-rules':
|
||||
return '그 직원의 필터가 이미 50개라 넘기기 규칙을 더할 수 없습니다 — 필터를 줄인 뒤 다시 하세요.';
|
||||
case 'not-signed-in':
|
||||
return '관리자 로그인이 끊겼습니다(비밀번호가 바뀌었을 수 있습니다). 도구를 다시 시작하세요.';
|
||||
case 'refused':
|
||||
return `엔진이 받지 않았습니다 (${error.detail ?? '까닭 모름'}).`;
|
||||
case 'unavailable':
|
||||
return `메일 엔진에 닿지 못했습니다 (${error.detail ?? '까닭 모름'}). 잠시 뒤 다시 하세요.`;
|
||||
}
|
||||
}
|
||||
if (error instanceof BusinessException) {
|
||||
if (error.code === 'MAIL_001') {
|
||||
return '메일 엔진에 닿지 못했거나 그 직원의 메일함을 열 수 없습니다(잠김 · 비밀번호 만료). 잠시 뒤 다시 하세요.';
|
||||
}
|
||||
if (error.code === 'AUTH_001') {
|
||||
return '그 직원의 메일함을 열 수 없습니다(계정 없음 · 잠김).';
|
||||
}
|
||||
return `처리하지 못했습니다 (${error.code}).`;
|
||||
}
|
||||
// 모르는 오류 — 부르는 쪽(failureLine)이 오류 로그에 원인을 남긴다
|
||||
return '처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.';
|
||||
}
|
||||
|
||||
/** 실행 기록에 남길 실패 요약 — 종류 · 코드만 */
|
||||
export function failureDetail(error: unknown): string {
|
||||
if (error instanceof OpsEngineError) {
|
||||
return `engine:${error.kind}${error.detail ? `/${error.detail}` : ''}`;
|
||||
}
|
||||
if (error instanceof BusinessException) {
|
||||
const reason: unknown = error.context?.reason;
|
||||
return `${error.code}${typeof reason === 'string' ? `/${reason}` : ''}`;
|
||||
}
|
||||
return error instanceof Error ? error.name : 'unknown';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user