generated from ttipo/project-base
Compare commits
176 Commits
30b3d28996
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8a5114af9 | |||
| 198ac8f4f2 | |||
| b644e8b104 | |||
| bd916ccbed | |||
| 94ec659b78 | |||
| 487760bd9b | |||
| 37ffcfca2c | |||
| f4a85fda27 | |||
| 0ff1ec46af | |||
| f3586abcd7 | |||
| 6eba013416 | |||
| f0e9a50b01 | |||
| 5ce614cc0c | |||
| def96e833b | |||
| 1d01130c9d | |||
| 1b3f9ba7fe | |||
| cd958f3bf4 | |||
| abe4c1364c | |||
| 1962f07d79 | |||
| d6b804ba83 | |||
| 19dbb2babb | |||
| 907287021f | |||
| 0c3238936a | |||
| fceb6ed3e4 | |||
| 060265c8b0 | |||
| 071fe9a60f | |||
| 3c9b3727c6 | |||
| 7f39aa4eb2 | |||
| d02a983d57 | |||
| 1ca210dec4 | |||
| 099faeaafc | |||
| ff1948b7df | |||
| 7c70862647 | |||
| e9acb7809c | |||
| 433ef12cc3 | |||
| f2ba81513e | |||
| 1f8555340e | |||
| 178a53fd6f | |||
| 117ec6c767 | |||
| 644c6e9424 | |||
| c1d0808764 | |||
| 9bfa53c03a | |||
| 1448835aff | |||
| 41f220bf61 | |||
| dacc100d3e | |||
| f433f941e6 | |||
| 57340d43df | |||
| 5670017d2a | |||
| 3a25cc52f7 | |||
| d4ab06183c | |||
| 3f66f39f9d | |||
| b77f95fdff | |||
| c0edff10d1 | |||
| 6ecc38d927 | |||
| 08b8144087 | |||
| e03eda1fd3 | |||
| bf725ae96d | |||
| 5b96ec2e85 | |||
| f0ef782b24 | |||
| 9c5db05ada | |||
| facf9cd936 | |||
| bec78bce42 | |||
| 3278e1abc1 | |||
| e0036000e9 | |||
| 1bd4b3d626 | |||
| c9e6858b2a | |||
| 76cbdcf74c | |||
| ca6c68c34f | |||
| 23afa583c1 | |||
| 4ca2d41bad | |||
| 97a989be07 | |||
| 37f078e65c | |||
| b9336b6db5 | |||
| 438dac8e01 | |||
| 88ec6433b9 | |||
| 25280d8e90 | |||
| 94da1d2031 | |||
| 7546af0243 | |||
| 3a126bdf83 | |||
| cc4f550044 | |||
| 749997cb18 | |||
| f39f41ed4b | |||
| 80e48d318d | |||
| 695a343878 | |||
| 20cde01454 | |||
| 08aafdef5c | |||
| 9ba4bc1a41 | |||
| 0090a161ec | |||
| f4b1d0a206 | |||
| a37045a553 | |||
| 5cece8ebf6 | |||
| 726f9f854f | |||
| ef23afc94c | |||
| ad8b126abb | |||
| 67f63f05fc | |||
| 39623ae3a8 | |||
| a111cd2fd4 | |||
| 5d3a018708 | |||
| 6ee6c71ace | |||
| 4e779dedd2 | |||
| c14580e3a4 | |||
| dd02b9621a | |||
| 2bb7fa4cdb | |||
| dc0fb81387 | |||
| 35fcf5e4ef | |||
| 5823f600aa | |||
| bb560d39f9 | |||
| fe355f3943 | |||
| 1a4f1d3b56 | |||
| 8f02814a4f | |||
| 37d689e113 | |||
| ab16379836 | |||
| 59375f1347 | |||
| 425cbc0e29 | |||
| 53a8d7176d | |||
| f9fbf8a20f | |||
| d57e6bfe6c | |||
| 0fc5735959 | |||
| 9887035bcd | |||
| f49dc2a186 | |||
| c476a4ecf4 | |||
| ba80956d7e | |||
| 1171593724 | |||
| 0021c0a1fc | |||
| cdebcb85f9 | |||
| cda4093ffd | |||
| ddfb2d14ab | |||
| 7d0d5f34c0 | |||
| 9c8728138e | |||
| 8abcaed98e | |||
| bdd42a3cb2 | |||
| bc22f5f3ba | |||
| 349c312989 | |||
| 2cd2764da8 | |||
| 6dc2777834 | |||
| 3b59497d59 | |||
| 5829814dce | |||
| 3f8ba0081a | |||
| 9b530da6e1 | |||
| feabe0d8e1 | |||
| 0388cf609a | |||
| 07b7fcca0f | |||
| b43b747107 | |||
| 34d63cf740 | |||
| e664217485 | |||
| 9c232b575d | |||
| 24179edaaa | |||
| c8287c4819 | |||
| 6106351351 | |||
| a7b9d00931 | |||
| 6c8c54d117 | |||
| ad6cea79fc | |||
| 1881ec7a42 | |||
| 452d9fef1d | |||
| 73babeaa2c | |||
| f0e7890bf1 | |||
| 781ffb2e16 | |||
| 9a72ce91e1 | |||
| d1ef0da488 | |||
| c31442e639 | |||
| f9e3b1c57a | |||
| adb992e6d8 | |||
| a8ea204e47 | |||
| 8f0a6da83c | |||
| 3461c361f8 | |||
| 93154ad9a5 | |||
| 93147b88b7 | |||
| c5cc1a6705 | |||
| c3dba24be0 | |||
| 15141e7723 | |||
| 9466a85daa | |||
| 4047bd30e7 | |||
| 3029bb20f2 | |||
| 867543c49f | |||
| 258ebf2180 | |||
| 4c1737f222 |
@@ -26,7 +26,7 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. 만들기 전에 `/nestjs` 의 "템플릿 기본 제공 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
1. 만들기 전에 `/nestjs` 의 "기본 골격"에서 재사용할 패턴을 찾는다. 같은 역할을 새로 만들지 않는다.
|
||||
2. 실패 경로마다 에러 코드를 정한다. 코드 없는 실패는 만들지 않는다 ([[error-codes]]).
|
||||
3. 목록 API 면 페이지네이션을 적용한다 ([[response-format]]).
|
||||
4. Swagger 데코레이터를 함께 쓴다 ([[api-design]]).
|
||||
|
||||
@@ -23,15 +23,16 @@ tools: Read, Grep, Glob, Edit, Write, Bash, Skill
|
||||
|
||||
## 작업 순서
|
||||
|
||||
1. `lib/features/user/` 의 model → repository → provider 수직 슬라이스를 복제한다.
|
||||
2. 네트워크는 `core/api/dio_client.dart` 의 인터셉터 패턴을 재사용한다. 새 클라이언트를 만들지 않는다.
|
||||
3. 에러 분기는 `e.error as ApiException` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
|
||||
4. 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` 을 쓴다.
|
||||
1. 새 기능은 그 모듈 폴더(`lib/features/<모듈>/`)와 그 모듈의 라우트 묶음(`<모듈>_routes.dart`)에 둔다. 새 모듈은 스킬의 "새 모듈 붙이는 법"대로. 모듈끼리 import 하지 않는다.
|
||||
2. `lib/features/auth/` 의 model → repository → provider 수직 슬라이스를 복제한다(목록 페이지네이션은 `features/user` 예제).
|
||||
3. 네트워크는 `dioProvider` 의 Dio 를 쓴다(인터셉터 차례 — 스킬 "API 통신"). 새 클라이언트 · 인터셉터 줄을 만들지 않는다.
|
||||
4. 에러 분기는 `apiErrorCodeOf(e)` 의 **`code`** 로 한다. 문구를 새로 만들지 않는다.
|
||||
5. 화면은 `docs/design/app/README.md` 와 캔버스 확정본대로 만든다 — 그 전에는 자리 화면(`PlaceholderPage`). 하드코딩 픽셀 대신 `MediaQuery`/`Expanded`/`Flexible` · 테마 상수를 쓴다.
|
||||
|
||||
## 완료 기준 (실행하고 결과를 보고한다)
|
||||
|
||||
```bash
|
||||
cd flutter && flutter analyze && flutter test
|
||||
cd flutter && flutter analyze && flutter test && flutter build apk --debug
|
||||
```
|
||||
|
||||
- `flutter/.env` 가 없으면 analyze 가 asset 경고를 낸다 — `.env.example` 복사 여부를 먼저 확인한다.
|
||||
|
||||
@@ -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단계)
|
||||
|
||||
## 하지 않는 것
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
# 작업 점검 & 신규 프로젝트 셋업
|
||||
# 작업 점검 & 새 환경 셋업
|
||||
|
||||
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
|
||||
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
> 여기에는 **언제나 유효한 점검 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다 — 지금 상태는 각 스킬 · API 계약 · [로드맵](../../docs/roadmap.md)).
|
||||
> 각 스택에 이미 있는 골격과 확장 규칙은 [[nestjs]] · [[vue3]] · [[flutter]] · [[docker]] 스킬 참조.
|
||||
|
||||
---
|
||||
|
||||
## 1. 신규 프로젝트 셋업 (클론 직후 1회)
|
||||
## 1. 새 개발 환경 · 새 서버 셋업 (처음 한 번)
|
||||
|
||||
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
|
||||
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1. 프록시를 다른 프로젝트와 함께 쓰거나 다른 서버에 두면
|
||||
주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 IP>` — [[docker]] "외부 리버스 프록시 요구사항")도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
|
||||
대용량 첨부를 쓰면 운영은 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 포털과 다른 호스트, `CORS_ORIGIN` 에 넣지 않는다)과 `CLAMAV_HOST=clamav` 도 필수다.
|
||||
그 호스트의 DNS · TLS · 외부 프록시 Proxy Host(같은 frontend:8080, 접근 로그 끔)를 함께 만든다 ([[docker]] "외부 리버스 프록시 요구사항")
|
||||
12단계부터 운영은 `MAIL_WEBHOOK_SECRET`(엔진 웹훅 서명 키 — 영문 · 숫자 32자 이상, `openssl rand -hex 24`)도 필수다 — setup 과 backend 가 같은 값을 읽는다
|
||||
13단계부터 운영은 `DB_IMAGE` 를 예시의 고정값(판 · digest)으로 두고, 첫 setup 은 복구 관리자와 함께(`--recovery` — 관리자 허용 IP 를 처음 걸 때 되돌릴 길) 돌린다.
|
||||
이미 운영 중인 서버는 mail/README "13단계 전부터 운영 중인 서버" 차례를 따른다
|
||||
- [ ] 운영 백업 · 감시(13단계 — 호스트 systemd, mail/README "백업 › 설치" · "감시"): Object Storage 비공개 버킷 + Object Storage 만 쓰는 하위 계정 키(버킷에는 따로 키가 없고 NCP 는 키를 버킷 하나로 좁히지 못한다 — 같은 계정에 다른 버킷을 두지 않는다), 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 경고를 낸다**
|
||||
@@ -77,10 +85,10 @@
|
||||
|
||||
## 6. 기능 확장 시 따르는 순서
|
||||
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"템플릿 기본 제공 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
새 기능은 만들기 전에, 해당 스킬의 **"기본 골격"** 에서 재사용할 패턴을 먼저 찾는다(같은 역할을 새로 만들지 말 것).
|
||||
|
||||
1. 해당 디렉터리 스킬 규칙 확인 ([[nestjs]] / [[vue3]] / [[flutter]])
|
||||
2. 기본 제공 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
2. 기본 골격(인터셉터·필터·에러 레지스트리·dio client·예제 모듈)에서 패턴 재사용
|
||||
3. 표준 응답/에러 흐름 준수 ([[response-format]] · [[error-codes]] · [[api-design]])
|
||||
4. 실패 경로마다 에러 코드 지정 — `BusinessException('CODE')` 로 던진다
|
||||
5. env 신규 값은 루트 `.env` 부터 ([[env-structure]])
|
||||
|
||||
@@ -14,10 +14,11 @@ description: 세 클라이언트(backend/frontend/flutter) 공통 코딩 규칙
|
||||
Dart `const` 누락·await 누락, backend 에서 `HttpException` 직접 생성(→ `BusinessException` 사용)
|
||||
- 유틸 함수는 순수 함수로 분리 — TS는 `shared/utils/`, Dart는 `core/utils/`
|
||||
- 매직 넘버·하드코딩 문자열 지양 → 상수 또는 환경변수로 분리
|
||||
(반복 사용되는 키 문자열도 상수로: 예 `kJwtTokenKey`, `REQUEST_ID_HEADER`)
|
||||
(반복 사용되는 키 문자열도 상수로: 예 `kRefreshTokenKey`, `REQUEST_ID_HEADER`)
|
||||
- 절대경로 alias 사용 (TS `@/`, Dart `package:`), 깊은 상대경로(`../../`) 지양
|
||||
— backend/frontend 모두 `@/` → `src/` 로 설정되어 있다 (backend 는 `tsconfig.json` paths + jest `moduleNameMapper`)
|
||||
- **자동 생성 파일은 직접 수정하지 않는다** — `*.generated.ts`, `*.g.dart` 는 생성 스크립트를 통해서만 바꾼다 ([[error-codes]])
|
||||
- 소스에 보이지 않는 글자(nbsp · U+2028 · U+FEFF 등)는 `\uXXXX` 이스케이프로 적되, **저장한 뒤 바이트로 확인한다** — 편집 도구가 U+00A0 이상의 이스케이프를 실제 글자로 풀어 저장한 일이 있다(2026-11-10 — 정규식 안의 U+2028 이 줄 끝이 되어 컴파일 실패, 16단계에서는 U+FEFF). 풀리면 `String.fromCharCode(0x2028)` 처럼 글자 코드로 만든다
|
||||
|
||||
## 네이밍 규약
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
|
||||
# 환경변수 구조 (단일 진실 공급원: 루트 `.env`)
|
||||
|
||||
이 템플릿은 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
이 저장소는 **루트 `.env.{APP_ENV}` 가 모든 설정의 단일 진실 공급원(SSOT)** 이다.
|
||||
값을 한 곳에서만 관리하기 위해, 서비스(backend/frontend)는 자체 env에 값을 중복 작성하지 않고 **루트 → docker-compose 가 주입한 값을 그대로 사용**한다.
|
||||
|
||||
```
|
||||
@@ -16,26 +16,45 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
|
||||
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
|
||||
│ 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 사용)
|
||||
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
|
||||
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
|
||||
│ MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP → mail-dns(mail ↔ 메일 전용 DNS 리졸버, internal) 서브넷 · 리졸버 주소 고정
|
||||
│ (mail 의 dns · 엔진의 DNS 가 이 IP 를 쓴다 — 둘을 함께 바꾸고 setup 을 다시 실행한다)
|
||||
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
|
||||
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
|
||||
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
|
||||
├─▶ files : 대용량 첨부(11단계) — backend 에 SHARE_PUBLIC_URL·SHARE_REPORT_ADDRESS·SHARE_DOWNLOAD_LIMIT·MAIL_LARGE_FILE_*·
|
||||
│ FILE_STORAGE_MAX_TOTAL_GB·FILE_STORAGE_MIN_FREE_GB·CLAMAV_HOST·CLAMAV_PORT (비운 값은 백엔드 기본값)
|
||||
│ 파일은 named volume <PROJECT_NAME>_files — 개발도. 경로 FILE_STORAGE_DIR=/app/files 는 compose 가 고정한다(루트 env 에 두지 않는다)
|
||||
│ 악성 검사기 clamav · clamav-updater 는 운영은 늘 뜨고 개발은 COMPOSE_PROFILES 에 clamav 를 넣고 CLAMAV_HOST=clamav 를 줄 때만
|
||||
├─▶ mail : MAIL_PUBLIC_URL·MAIL_RECOVERY_ADMIN → STALWART_* (backend 에는 MAIL_JMAP_URL·MAIL_DOMAIN·MAIL_GATEWAY_*·MAIL_ATTACHMENT_*·
|
||||
│ MAIL_ADMIN_LOCAL_PART·MAIL_BLOCKED_ACCOUNTS·MAIL_DIRECTORY_LOCAL_PART)
|
||||
│ 운영의 MAIL_RECOVERY_ADMIN 은 파일에 두지 않고 셸 일회성 override 로만 준다 (mail/README.md "운영 배포")
|
||||
│ MAIL_ADMIN_LOCAL_PART 는 setup.mjs 와 backend 가 함께 읽는다 — bootstrap 전에만 정할 수 있다
|
||||
│ MAIL_DIRECTORY_LOCAL_PART(사내 목록 계정, 기본 mail-directory)도 함께 읽는다 — 계정은 setup 이 복구 관리자로만 만든다(운영 --recovery)
|
||||
│ MAIL_WEBHOOK_SECRET(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상)도 setup · backend 가 함께 읽는다 — 운영 필수, 바꾸면 setup 다시 + backend 다시 띄우기
|
||||
│ backend 에는 APP_NETWORK_SUBNET 도 넘긴다(웹훅 받는 쪽이 접속 주소가 앱 네트워크인지 본다)
|
||||
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
|
||||
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
|
||||
BACKEND_PORT · MAIL_WEBHOOK_SECRET 도 읽는다(12단계 — 엔진 웹훅 주소 http://backend:<BACKEND_PORT>/… 와 서명 키, 셸 값과 다르면 멈춘다)
|
||||
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
|
||||
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
|
||||
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
- **새 설정값은 루트 `.env.{APP_ENV}` 에 먼저 추가**하고, 필요한 서비스에 `docker-compose.yml` 의 `environment` / `args` 로 전달한다.
|
||||
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`)
|
||||
- 서비스 자체 env 파일에는 **루트에서 내려주지 않는 고유 값만** 작성한다. (예: 백엔드 `JWT_SECRET`,
|
||||
앱 새 메일 알림의 `FCM_SERVICE_ACCOUNT_KEY` — 앱 1단계, **선택**: Firebase 서비스 계정 JSON 키를 통째로 base64 한 줄. 비면 알림만 끄고 기동한다(운영 필수 아님 — 기동 warn ·
|
||||
`GET /api/app-config` 의 `pushAvailable: false`). 값이 있으면 모든 환경에서 키 모양을 보고 틀리면 기동하지 않는다(어느 칸이 틀렸는지만 — 값은 싣지 않는다, `env.validation.ts` 의 `validateFcmSettings`).
|
||||
개발 · 운영 키를 따로 만든다. 매일 백업이 `backend/.env.production` 을 함께 올린다)
|
||||
- **compose 는 정의되지 않은 변수를 빈 문자열로 주입한다.** 코드에서 `??` 기본값만 쓰면 `Number('')===0` 같은 함정에 빠진다 —
|
||||
숫자 설정값은 `shared/utils/env.ts` 의 `parsePositiveInt()` 처럼 빈 문자열까지 방어하는 파서를 거친다.
|
||||
(기존 `.env` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
|
||||
@@ -49,10 +68,17 @@ 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` 을 주면 개발 값으로 돈다.
|
||||
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
|
||||
예외는 복구 관리자 `MAIL_RECOVERY_ADMIN`(원래 셸에서 일회성으로 주는 값이라 검사하지 않는다)과 파일럿 키 둘이다 — 파일럿 키는 개발은 한 번 시험하려고 셸로 줄 수 있고(`[참고]` 를 낸다),
|
||||
운영은 반대로 파일에 없는 파일럿 값이 셸에 있으면 중단한다 (파일럿은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 한다). 운영에서 파일럿을 끄는 것은 값을 비우고 `--end-pilot` 을 줄 때뿐이다 — 값이 빠졌다고 저절로 끄지 않는다.
|
||||
Spamhaus 키 `MAIL_SPAMHAUS_DQS_KEY` 도 운영은 파일에만 둔다 — 셸에만 있으면 그 실행만 켜지고 다음 실행이 끄므로 중단한다.
|
||||
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
|
||||
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
|
||||
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
|
||||
@@ -60,20 +86,29 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
|
||||
- 숫자 항목은 형식 검사 (모든 환경)
|
||||
- 운영 환경 필수: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`, `JWT_SECRET`, `MAIL_JMAP_URL`/`MAIL_DOMAIN`/`MAIL_GATEWAY_*`,
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부)
|
||||
`CORS_ORIGIN`(화면의 공개 주소 — 상태 변경 요청의 출처 검사), `TRUST_PROXY`(백엔드 앞 프록시 홉 수 — 운영에서 `true`·`0`·`false` 는 거부),
|
||||
`SHARE_PUBLIC_URL`(대용량 첨부의 공개 내려받기 출처 — https), `CLAMAV_HOST`(악성 검사기 — 비면 검사 없이 받을 수 있게 되므로),
|
||||
`MAIL_WEBHOOK_SECRET`(12단계 — 엔진 웹훅 서명 키, 영문 · 숫자 32자 이상. 예시 값 `CHANGEME…` 는 setup · backend 모두 거부)
|
||||
- 운영 환경: **`SHARE_PUBLIC_URL` 의 호스트를 `CORS_ORIGIN` 에 넣지 않는다** — 같은 호스트면 공개 호스트 분리(포털에서 공개 링크를 닫는 것)가 꺼져 기동을 막는다.
|
||||
`SHARE_PUBLIC_URL` 의 모양은 백엔드와 nginx 템플릿(`nginx-env.envsh`)이 같은 규칙으로 본다 — 출처만(경로 · 끝의 `/` · 끝 점 없이). 한 번 정하면 바꾸지 않는다(보낸 메일의 링크가 그 주소다)
|
||||
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
|
||||
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
|
||||
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL`·`SHARE_PUBLIC_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
|
||||
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
|
||||
로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓸 때만 `ALLOW_LOCALHOST_URL=true` 로 건너뛴다
|
||||
(`BACKEND_API_URL` 은 앱도 프론트 빌드도 쓰지 않는다 — 같은 주소 배포라 "화면 주소 + /api" 이고, 이 검사를 위해서만 compose 가 backend 에 주입한다)
|
||||
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
|
||||
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
|
||||
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
|
||||
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> 루트 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`·`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 전에만)·
|
||||
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
|
||||
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
|
||||
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)
|
||||
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
|
||||
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·
|
||||
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)·
|
||||
> `MAIL_WEBHOOK_SECRET`(비밀 — 엔진 웹훅 서명 키, setup · backend 공용, 운영 필수 — 만들기 `openssl rand -hex 24`)
|
||||
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
|
||||
@@ -29,25 +29,30 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
|
||||
| 코드 | HTTP | 상황 | 메시지 |
|
||||
| --- | --- | --- | --- |
|
||||
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| REQ_001 | 429 | Rate Limit 초과 | "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "이메일 또는 비밀번호가 올바르지 않습니다." |
|
||||
| SYS_001 | 500 | 미정의 예외·네트워크 장애 (최종 폴백) | "문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| REQ_001 | 429 | Rate Limit 초과 · 실패 횟수 한도 · 같은 일이 진행 중 | "너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요." |
|
||||
| AUTH_001 | 401 | 미인증 상태로 보호 자원 접근 | "로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요." |
|
||||
| AUTH_002 | — | **클라이언트 전용** · 세션 만료 | "안전을 위해 로그아웃했습니다. 다시 로그인해 주세요." |
|
||||
| AUTH_003 | 403 | 권한 부족 | "이 기능을 쓸 수 있는 권한이 없습니다." |
|
||||
| AUTH_004 | 401 | 로그인 자격 증명 불일치 (계정 없음·비밀번호 틀림을 구분하지 않음) | "메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요." |
|
||||
| AUTH_005 | 400 | 로그인한 채 본인 확인 실패 — 비밀번호 변경의 지금 비밀번호가 틀림. **401 이 아니다** — 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
| 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 | 그 외 비즈니스 규칙 위반 | "하려던 일을 마치지 못했습니다. 다시 시도해 주세요." |
|
||||
|
||||
### AUTH_002 (클라이언트 전용)
|
||||
|
||||
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
|
||||
|
||||
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
|
||||
웹은 그 전에 `/auth/refresh` 를 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
|
||||
웹 · 앱은 그 전에 갱신을 한 번 시도하고, 갱신까지 실패했을 때만 승격한다 (`docs/mail/api-contract.md` 1절).
|
||||
- 웹: `useApi.ts` 의 `toAuthCode()` — `authStore.expireSession()` 을 불러 세션 만료 대화상자(`SessionExpiredDialog.vue`)를 띄운다
|
||||
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
|
||||
- 앱: `core/api/auth_interceptor.dart` · `token_refresher.dart` — 웹처럼 갱신(`/auth/app/refresh`)을 한 번 해 보고, 갱신까지 거절됐을 때만 승격한다 (리프레시 토큰 삭제 · 로그인 화면 — `docs/mail/api-contract.md` 1절 "앱 로그인"). 그 요청을 보낸 세션이 이미 끝났으면(세션 세대가 다르다) 알리지 않는다 — 사용자가 스스로 로그아웃했으면 승격하지 않고 `AUTH_001` 그대로(웹 `endedBySignOut` 과 같다), 세션이 끝난 뒤면 `AUTH_002`
|
||||
|
||||
## 도메인 코드 추가
|
||||
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.**
|
||||
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다)
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
|
||||
- 사용자가 친 검색어(쿼리의 `q` — 메일 검색 · 받는 사람 추천) — 요청 URL 을 남길 때는 `shared/utils/mask.ts` 의 `maskUrlForLog()` 를 거친다(`q` · 조건 검색의 `from` · `to` · `subject` · `body`(14단계) · `cursor` 는 길이만 — 목록 커서에는 검색어의 지문이 들어 있어 후보 낱말을 대입해 볼 수 있다).
|
||||
앞단 nginx 도 접근 로그에 쿼리를 남기지 않는다 — `/api` 와 화면 요청 모두(메일 검색은 화면 주소 `/mail/search?q=…` 에도 검색어가 들어간다, [[docker]]).
|
||||
**예외**: nginx 오류 로그(413 · 502 · 504 등)는 장애 원인 줄을 잃지 않게 요청 줄 · referrer 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
|
||||
|
||||
@@ -47,6 +49,8 @@ this.logger.log(`로그인 시도: email=${maskEmail(dto.email)}`);
|
||||
|
||||
- 마스킹 유틸은 순수 함수로 `shared/utils/` 에 둔다 ([[coding-common]]).
|
||||
|
||||
- 메일 프로그램 자동 설정(`autoconfig.` · `autodiscover.` — 13단계)의 요청 주소에는 직원 주소가 쿼리로 실린다(`?emailaddress=`) — 앞단 nginx 는 쿼리 없이 남기고(위 규칙 그대로), 외부 프록시의 그 Proxy Host 는 접근 로그를 끈다 ([[docker]]).
|
||||
|
||||
## 클라이언트 로깅
|
||||
|
||||
- 프론트/앱에서 사용자 정보를 콘솔에 출력하지 않는다. 운영 빌드에는 디버그 로그를 남기지 않는다.
|
||||
@@ -56,5 +60,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]] 참조.
|
||||
|
||||
+67
-13
@@ -11,8 +11,8 @@
|
||||
|
||||
## 인증 · 토큰
|
||||
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
|
||||
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
|
||||
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 리프레시 토큰만 `flutter_secure_storage`(키: `refresh_token`), 액세스 토큰은 메모리 — 앱 전용 토큰 경로(계약 `docs/mail/api-contract.md` 1절 "앱 로그인"), Android 백업 끔
|
||||
- Access Token 만료는 짧게(15분~1시간). Refresh Token 은 웹은 HttpOnly 쿠키로만, 앱은 1절 "앱 로그인"대로(본문으로 받아 보안 저장소에만 — 로그 · 일반 저장소에 두지 않는다)
|
||||
- 리프레시 토큰은 서버 세션에 **해시만** 저장하고 **1회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
|
||||
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
|
||||
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
|
||||
@@ -20,6 +20,26 @@
|
||||
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
|
||||
- **메일 게이트웨이(대리 접근) 계정은 관리자 계정까지 대리할 수 있다** (Stalwart 0.16 확인) — 게이트웨이 비밀번호는 관리자급 비밀로 다룬다.
|
||||
백엔드는 엔진 관리자 계정을 로그인·대리 접근 대상에서 막는다: 정적 목록(엔진에 묻기 전) + 로그인·리프레시 때 엔진 권한 확인 ([[nestjs]])
|
||||
- **2단계 인증(12단계 — 엔진 TOTP)**: 엔진은 등록 때 코드를 확인하지 않고(잘못 등록하면 직원이 끄지도 못한다) 같은 코드를 다시 받으므로 서버가 막는다 —
|
||||
등록 때 첫 코드 확인, 성공에 쓴 코드를 2분 동안 다시 받지 않기(확인하는 동안에도 — 같은 코드의 동시 요청이 둘 다 지나가지 않게), 코드 실패를 따로 세기(같은 주소 15분 5회 · 24시간 20회).
|
||||
코드 단계에서도 비밀번호를 먼저 엔진 Basic 으로 본다 — 엔진 `/api/auth` 의 failure 는 비밀번호 · 코드를 가르지 않아, 그대로면 비밀번호 추측이 코드 실패로 세진다. 비밀(TOTP 키)은 등록 10분 동안만 서버 메모리에 두고 켠 뒤에는 엔진에만 있다
|
||||
- **앱 비밀번호**(메일 프로그램용 — 엔진이 만든 40자): 웹메일 로그인 · 비밀번호 바꾸기는 그 모양(`app` + 1자 + 36자)을 엔진에 묻지 않고 거절한다(엔진에는 웹메일에서 빼는 설정이 없다).
|
||||
만들 때 권한을 메일 프로그램만(`Replace` — authenticate · imapAuthenticate · emailSend)으로 좁힌다 — 기본(`Inherit`)이면 앱 비밀번호로 JMAP 을 쓰고 앱 비밀번호를 또 만든다.
|
||||
비밀번호를 바꾸면 앱 비밀번호를 모두 지운다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게). 값은 만든 응답에만 싣는다(no-store — 로그 · DB 에 두지 않는다)
|
||||
- **끊은 세션은 바로 막는다**(12단계) — 액세스 토큰의 `sid` 와 서버가 1시간 기억한 끊은 세션(뜰 때 DB 에서 채운다)으로 가드가 거절한다. 액세스 토큰 만료까지 기다리지 않는다
|
||||
- **엔진이 부르는 길**(웹훅 — 로그인 기록)은 세 겹으로 받는다: 앞단 nginx 가 `/api/internal/` 을 닫고, 서버가 접속 주소(소켓 — `X-Forwarded-For` 가 아니다)가 앱 네트워크인지 본문을 받기 전에 보고,
|
||||
본문 원문의 HMAC-SHA256(`MAIL_WEBHOOK_SECRET`)을 상수 시간으로 견준다. 키가 비면 모두 거절한다
|
||||
- **제한된 세션**(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자 · 한 번만 보여 주고 · 엔진 만료(첫 로그인 때 바꾸기)와 함께 건다.
|
||||
이전용 비밀번호(메일 이전 — 2026-11-02)도 같은 무작위 · 한 번만이고 엔진 만료를 30일 뒤로 건다(운영 도구 한 번 실행 안에서는 같은 값 — 프로세스 메모리에만) — imapsync 의 비밀번호 파일(`--passfile` — 0600, 끝나면 지움)에만 넣고 명령줄에 싣거나 직원에게 주지 않는다. 마지막 imapsync 뒤 「이전 마치기」로 직원용 임시 비밀번호로 바꾼다
|
||||
- **메일 엔진 관리자 계정**(13단계 setup): 대리 접근 권한(`impersonate`)이 없고 허용 IP 는 앱 네트워크다 — 관리자 비밀번호가 새도 바깥 993 · JMAP 에서 직원 메일함에 들어가지 못한다.
|
||||
되돌리는 길은 복구 관리자뿐이라 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 한다(없으면 걸지 않고 멈춘다). 게이트웨이 비밀번호는 여전히 관리자급이다(위 줄)
|
||||
- **받은편지함 알림 메일**(서버가 넣는 시스템 메일)에는 링크를 넣지 않는다 — 할 일은 메뉴 경로 글로. "회사 보안 알림에는 링크가 없다"는 습관이 링크 달린 피싱을 드러낸다
|
||||
- **퇴사 마무리**(주소를 다시 쓸 수 있게): 엔진 계정을 지운 뒤 우리 DB 에서 그 주소로 키링한 행을 모두 지운다 — 남기면 같은 주소의 새 사람에게 옛 로그인 기록 · 2단계 설정 · 서명이 보인다.
|
||||
끊은 세션 행은 끊은 기록을 서버가 동기화로 읽어야 하므로 바로 지우지 않는다(정리 cron)
|
||||
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_004)
|
||||
- 로그인 시도의 실패는 AUTH_004, 로그인 없이 보호 자원에 접근한 것은 AUTH_001 이다.
|
||||
로그인 실패에 AUTH_001 을 쓰면 "로그인이 필요한 서비스입니다"가 떠 사용자가 원인을 알 수 없다.
|
||||
@@ -44,10 +64,13 @@
|
||||
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
- **예외 — 대용량 첨부(11단계 `modules/file-storage`)는 확장자 허용 목록만 본다.** 브라우저가 주는 형식은 dwg · psd · 7z 같은 파일에 믿을 근거가 없어(비거나 제각각)
|
||||
MIME 으로 거르면 정상 파일만 막힌다. 대신 세 겹으로 막는다: 내려줄 때 늘 `application/octet-stream` + `attachment` + `nosniff` + CSP `sandbox`(브라우저가 열지 않는다),
|
||||
허용 목록에 브라우저가 실행하는 형식이 없다, 내용은 ClamAV 가 본다(검사 전 · 중에는 받을 수 없다) — 계약 4절 "정한 것" 10.
|
||||
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
|
||||
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
|
||||
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
|
||||
@@ -63,6 +86,31 @@
|
||||
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
|
||||
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
|
||||
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
|
||||
> (서명 그림은 매직 바이트로 판별하고 — `modules/mail-signature/signature-image-format.ts`, 대용량 첨부는 악성 검사(ClamAV)가 내용을 본다.)
|
||||
|
||||
### 공개 링크 (로그인 없이 받는 길 — 11단계 대용량 첨부)
|
||||
|
||||
- 토큰은 서버가 만든 256비트(`randomBytes(32)` base64url)이고 **DB 에는 SHA-256 만** 둔다. 모양이 아니면 DB 에 묻지 않는다.
|
||||
원문 토큰은 로그 · 오류 context · 목록 응답 어디에도 두지 않는다 — 요청 주소는 `maskUrlForLog()`(경로 어디서든 `public-links` 뒤 조각을 가린다), 앞단 nginx 접근 로그도 가린다.
|
||||
- 공개 경로는 **따로 둔 공개 호스트**(`SHARE_PUBLIC_URL` — `files.`)에서만 열고 포털에서는 닫는다(nginx 의 호스트별 server + 백엔드 `public-host.ts` 두 겹).
|
||||
인증 쿠키는 host-only 라 공개 호스트로 가지 않는다. 운영에서 공개 호스트를 `CORS_ORIGIN` 에 넣으면 기동하지 않는다(분리가 꺼지므로).
|
||||
- 받은 수 상한은 한 문장의 조건부 UPDATE(`… WHERE count < limit RETURNING`)로 센다 — 읽고 나서 쓰면 동시 요청이 상한을 넘는다.
|
||||
- 버퍼링을 끈 내려받기는 연결 하나가 앞단 연결 둘과 백엔드 fd 를 잡는다 — **IP 마다 · 전체 동시 연결 상한**(nginx `limit_conn`)을 함께 둔다.
|
||||
전체 상한은 경로(공개 · 앱 안)마다 따로 — 함께 세면 바깥 주소 몇십 개로 직원의 받기까지 막힌다.
|
||||
**앞단과 백엔드가 같은 경로를 같게 읽어야 한다** — 백엔드가 대소문자를 가리지 않던 때 `/FILES/` 처럼 글자 하나로 nginx 의 상한 location 을 비켜 갔다(2026-10-30 리뷰).
|
||||
그래서 nginx 의 받기 location 은 대소문자를 가리지 않는 정규식이고, 백엔드 라우팅은 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
|
||||
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 0x80 이상 바이트는 nginx · 백엔드 모두 400 이다 — Express 는 이런 대상을 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔,
|
||||
nginx 가 닫은 `/api/internal/` · `/api/health` 가 백엔드에서 열렸다(2026-10-31 리뷰).
|
||||
|
||||
### 앱 푸시 (휴대폰 새 메일 알림 — 앱 1단계 · FCM)
|
||||
|
||||
- **글은 "새 메일 N통"뿐이다** — 주소 · 이름 · 보낸 사람 · 제목 · 메일 id · 세션 id 는 Google 로 보내지 않는다. 메시지는 한 곳(`modules/push/fcm/fcm-message.ts`)에서 종류 · 개수만 받아 만든다(받는 칸이 없다),
|
||||
새 메일을 알아채는 쪽도 엔진 연결에서 신호만 받는다. Google 은 기기마다 언제 몇 통이 왔는지는 안다(알려진 한계).
|
||||
- **휴대폰 주소(FID)는 한 세션에만** — 같은 FID 를 다른 세션이 등록하면 앞 줄을 지운다(같은 휴대폰에서 다른 직원이 로그인해도 앞 직원의 알림이 가지 않게). 직원마다 10개까지.
|
||||
- **보낼 때 살아 있는 · 제한 없는 앱 세션만 고른다** — 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 운영 도구)은 DB 의 `revoked_at` 으로 바로 빠진다. 등록 경로는 앱 토큰만(웹 쿠키 세션 403 `AUTH_003`).
|
||||
- **서비스 계정은 알림 전용 하나 + 역할 "Firebase Cloud Messaging API Admin" 하나** — Firebase 의 기본 Admin SDK 계정(Firestore · Auth 까지 쓴다)을 쓰지 않는다. 키는 개발 · 운영 따로,
|
||||
`backend/.env.{env}` 의 `FCM_SERVICE_ACCOUNT_KEY` 에만 둔다(로그 · 오류 · 응답에 싣지 않는다). 새면 그 키를 Google Cloud 콘솔에서 지우고 새 키로 — 그 키로는 우리 앱 사용자에게 꾸민 알림을 보낼 수 있다(메일은 읽지 못한다).
|
||||
- 바깥 주소는 상수 두 곳(`oauth2.googleapis.com` · `fcm.googleapis.com`)뿐이다 — 키 파일의 `token_uri` 를 따라가지 않고(다르면 기동하지 않는다) 다른 곳으로 보내는 응답(3xx)도 따라가지 않는다.
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
@@ -83,10 +131,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 에 접속할 수 있다.
|
||||
@@ -117,32 +169,33 @@
|
||||
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
|
||||
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
|
||||
|
||||
> **현재 유예 중인 항목** (2026-10-27 점검):
|
||||
> **현재 유예 중인 항목** (frontend 2026-10-27 · backend 2026-11-05 점검):
|
||||
> - frontend `eslint-plugin-vue@9` → `postcss-selector-parser` (moderate, 선택자 파싱 CPU 과다 사용)
|
||||
> - 도달 경로: 개발 시 린트에서만 돈다. 입력은 우리 소스 코드뿐이라 외부 공격 경로가 없다. 운영 번들·이미지에 포함되지 않는다.
|
||||
> - 미루는 이유: 수정판이 `eslint-plugin-vue@10`(메이저)이라 린트 규칙이 바뀌고 설정 이관이 필요하다.
|
||||
> - 다시 볼 시점: ESLint 스택을 올릴 때, 늦어도 2027-01.
|
||||
> - backend (2026-10-27 기준 7건 — high 3·moderate 4, 운영 의존성만 보면 6건 — 아래 5종):
|
||||
> - backend (2026-11-05 기준 23건 — moderate 21 · low 2 · high 0, 운영 의존성만 보면 5건 — 아래 셋):
|
||||
>
|
||||
> | 항목 | 도달 경로 | 미루는 이유 | 다시 볼 시점 |
|
||||
> | --- | --- | --- | --- |
|
||||
> | `body-parser` 2.3.0 (high, 압축 본문 DoS) | 없음 — Nest 기본 파서를 끄고 JSON 파서만 `inflate:false` 로 둔다. 압축 본문은 해제 전에 415 (실서버 확인) | 수정판 2.4.0 이 2026-10-22 배포 (2주 미만) | 2026-11-05 에 lock 갱신 (express 5.3.0 이 `^2.3.0` 이라 범위 안) |
|
||||
> | `multer` 2.4.0 (high, 비동기 fileFilter DoS) | 없음 — 우리 fileFilter(upload·mail)는 모두 동기 | 2.5.0 이 2026-10-22 배포 (2주 미만). `@nestjs/platform-express` 최신 12.2.0 도 2.4.0 고정이라 override 를 올릴 근거가 아직 없음 | 2026-11-05 이후 override `^2.5.0` 여부 결정 |
|
||||
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.x 가 5.3.0 으로 정확히 고정 (12.x 는 5.5.0 이지만 메이저) | swagger 11.x 가 ≥5.4.1 을 선언한 릴리스 + 2주. 또는 override `^5.4.3` 검토 (swagger 12 가 5.x 를 쓰는 것이 근거) |
|
||||
> | `fast-uri` 3.1.8 (moderate, 개발 전용) | 없음 — Nest CLI 의 ajv 에서만 쓰이고 운영 이미지에 없다 | 3.1.9 가 2026-10-19 배포 (2주 미만) | 2026-11-02 |
|
||||
> | `sanitize-html` 2.18.0 (moderate, 버린 iframe 의 대체 내용 재귀 — GHSA-c9rx) | 없음 — 우리 설정은 iframe 을 내용째 버려 대체 내용을 다시 정화하지 않는다 (5만 단계 중첩으로 확인, 2만 단계 회귀 테스트로 고정) | 수정판 2.18.1 이 2026-10-14 배포 (2주 미만) | 2026-10-28 이후 2.18.1 |
|
||||
> | `js-yaml` 5.3.0 (moderate, `@nestjs/swagger` 하위) | 없음 — swagger 는 `dump` 만 쓰고 우리 코드는 YAML 을 파싱하지 않는다 | swagger 11.4.8(2026-11-04)이 js-yaml 5.5.0 을 선언하지만 배포 2주 미만 | 2026-11-18 이후 `npm update @nestjs/swagger`(`^11` 범위 — lock 만, 컨테이너 안에서) |
|
||||
> | `glob` 10.5.0 · `minimatch` 9.0.9 (moderate, `typeorm` 0.3.31 하위 — 11-05 새 권고) | 없음 — typeorm 은 기동 때 `database.config.ts` 의 고정 패턴(entities · migrations)만 푼다. 외부 입력이 닿지 않고 우리 코드는 둘을 직접 쓰지 않는다 | 수정판이 메이저(glob 14 · minimatch 11, 2026-10-27)라 override 로 건너뛰지 않는다(아래 규칙 — 같은 메이저만) | typeorm 0.3.x 가 고친 glob 을 선언한 판 + 2주, 또는 typeorm 1.x 로 옮길 때 |
|
||||
> | 개발 전용 18건 — jest · `@nestjs/cli` · `@swc/cli` · typescript-eslint 아래의 glob · minimatch, `deepmerge`(low) | 없음 — 운영 이미지에 없다 | 상위 스택의 갱신 판이 2주 미만이거나 메이저 | 그 스택을 올릴 때. ⚠️ `npm audit fix` 를 통째로 돌리지 않는다 — 2026-11-05 dry-run 이 `@unrs/resolver-binding-*` 를 1.13 으로 바꾸고 2주 미만 판을 섞었다 |
|
||||
>
|
||||
> 2026-11-05 에 올린 것(모두 배포 2주 지난 판 — 컨테이너 안 `npm install … --before=<2주 전 날짜>` 로 골랐다): body-parser 2.3.0 → 2.4.0(lock 만 — express 5.2.1 의 범위 안, qs 6.16.1 · iconv-lite 0.7.3 이 따라옴),
|
||||
> multer override `^2.4.0` → `^2.5.0`, sanitize-html 2.18.0 → 2.18.1, fast-uri 3.1.8 → 3.1.9. 29건 → 23건(운영 11 → 5, high 3 → 0).
|
||||
>
|
||||
> 2026-10-27 Node 24 전환과 함께 sanitize-html 고정(2.17.5)을 풀어 이전 권고 4건을 해결했다.
|
||||
> 2.18 이 쓰는 htmlparser2 계열은 ESM 전용이라 Jest 설정에서 CJS 로 변환한다 ([[nestjs]] "의존성 추가/제거").
|
||||
>
|
||||
> 참고: `@nestjs/platform-express` 11.2.7(2026-09-30) 이상은 multer 2.4.0 을 직접 선언한다 — 올리면 아래 multer override 를 지울 수 있다.
|
||||
> 참고: `@nestjs/platform-express` 11.x 최신(11.2.8)도 multer 2.4.0 을 정확히 고정하고, 2.5.0 은 12.3.0 부터 선언한다 — multer override 는 platform-express 를 v12.3 이상으로 올릴 때 지운다.
|
||||
>
|
||||
> 같은 점검에서 정리한 것: frontend 22건 → 2건 (axios 1.20.0 · vue 3.5.43 · vite 8.3.4 · oxfmt 0.72.0 으로 갱신, lock 은 Linux 컨테이너에서 생성),
|
||||
> backend 12건 → 7건 (critical 0 — proxy-addr 2.0.8 등).
|
||||
> 새 버전은 배포 후 2주 이상 지난 패치 버전을 골랐다 (공급망 위험 — 갓 나온 버전은 피한다).
|
||||
> **예외: 우리에게 닿는 취약점을 고치는 보안 릴리스**는 2주를 기다리지 않고 CI 가 통과하는 대로 올린다 — 알려진 취약점을 2주 더 두는 위험이 더 크다
|
||||
> (예: Node 24.22.1 — 2026-10-20 보안 릴리스를 6일 만에 고정, 고정 위치는 [[docker]]).
|
||||
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 위 표의 sanitize-html 2.18.1).
|
||||
> 도달 경로가 없다고 확인한 취약점이면 평소처럼 2주를 기다린다 (예: 2026-11-05 에 올린 sanitize-html 2.18.1 · multer 2.5.0 · body-parser 2.4.0).
|
||||
|
||||
### 전이 의존성 취약점은 overrides 로 끊는다
|
||||
|
||||
@@ -152,13 +205,14 @@
|
||||
```jsonc
|
||||
// backend/package.json
|
||||
"overrides": {
|
||||
"multer": "^2.4.0"
|
||||
"multer": "^2.5.0"
|
||||
}
|
||||
```
|
||||
|
||||
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
|
||||
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
|
||||
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
|
||||
> 2026-11-05 multer 2.5.0(다음 DoS 수정)도 같은 길이다 — platform-express 12.3.0 이 2.5.0 을 선언하는 것이 근거, 2.4.0 과 의존성(busboy · type-is · append-field)도 같다.
|
||||
|
||||
규칙:
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
|
||||
# 테스트 규칙
|
||||
|
||||
> 템플릿은 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 각 스택에 테스트 러너가 준비되어 있다 — backend `jest`, frontend `vitest`, flutter `flutter_test`.
|
||||
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
|
||||
|
||||
## 공통 원칙
|
||||
@@ -13,11 +13,17 @@ description: 세 스택 공통 테스트 규칙 — 테스트 위치/네이밍,
|
||||
- **무엇을 테스트하나**: 순수 함수(utils), 서비스/비즈니스 로직, 에러 코드 매핑 분기를 우선한다. 프레임워크 기본 동작은 테스트하지 않는다.
|
||||
- **공유 계약은 반드시 테스트한다** — 아래 "계약 테스트" 절 참조.
|
||||
- **테스트는 결정적(deterministic)** 으로 — 외부 네트워크/시간/랜덤에 의존하지 않도록 모킹한다.
|
||||
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 프로세스 CPU 시간이나 비용 수치.
|
||||
- 성능 단언("입력이 커져도 오래 걸리지 않는다")은 **벽시계가 아니라 계산량**으로 잰다 — 지금 스레드의 user CPU 시간(`backend/test/cpu-time.ts`)이나 비용 수치.
|
||||
프로세스 전체 · system 시간은 쓰지 않는다 — V8 의 GC · JIT 도우미 스레드와 페이지 폴트가 기계 사정을 따라 흔들려, 2026-10-31 문서만 바꾼 PR 의 CI 에서 할당이 많은 단언 셋이 넘쳤다
|
||||
(같은 컨테이너 16회: 프로세스 user + system 으로 4회 넘침 · 스레드 user 로 0회).
|
||||
CPU 시간 한도는 **지금 값의 10배쯤** 둔다 — CPU 시간도 부하에서 는다(2026-10-28: CI 와 같은 Linux 컨테이너에서 메모리가 모자라고 테스트가 함께 돌 때
|
||||
평소의 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 제목)은 한국어로 "무엇을 검증하는지" 명확히 작성한다.
|
||||
|
||||
+220
-17
@@ -18,7 +18,8 @@ description: Docker Compose 기반 로컬·운영 오케스트레이션 실행/
|
||||
|
||||
```bash
|
||||
# 코드만 수정했을 때 (의존성 변경 없음) — backend + db 가 뜬다
|
||||
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절)
|
||||
# (예시 env 의 기본값 COMPOSE_PROFILES=mail 이면 메일 엔진 mail + 개발용 mailpit 도 함께 — 아래 "메일" 절.
|
||||
# 대용량 첨부의 악성 검사기까지 보려면 COMPOSE_PROFILES=mail,clamav 와 CLAMAV_HOST=clamav — 아래 "대용량 첨부" 절)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 의존성 변경 시에만 --build
|
||||
@@ -32,6 +33,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.d
|
||||
|
||||
> ⚠️ `.env.production` 의 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인으로 교체**해야 한다 (예시: `https://portal.arasystem.it.kr`).
|
||||
> `localhost` 나 예시 도메인(`example.com`)이 남아 있으면 백엔드가 기동에 실패한다 ([[env-structure]]). `CORS_ORIGIN`·`TRUST_PROXY` 는 운영 필수다.
|
||||
> 대용량 첨부(11단계)의 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — 예 `https://files.arasystem.it.kr`, **`CORS_ORIGIN` 에 넣지 않는다**)·`CLAMAV_HOST`(`clamav`)도 운영 필수다.
|
||||
> 로컬에서 운영 이미지를 검증하느라 일부러 localhost 를 쓰는 경우에만 `ALLOW_LOCALHOST_URL=true` 를 함께 준다.
|
||||
|
||||
```bash
|
||||
@@ -45,11 +47,16 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
### 같은 주소(origin) 배포 — 입구는 프론트 nginx 하나
|
||||
|
||||
```
|
||||
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ / 화면(SPA)
|
||||
클라이언트 ─TLS─▶ 외부 리버스 프록시 ─(proxy)─▶ <PROJECT_NAME>-frontend :8080 ─┬─ 포털 호스트 / 화면(SPA) · /api/
|
||||
(포털 · 공개 내려받기 두 호스트) ├─ 공개 호스트 /s/ 화면 · /api/public-links/ 만 (11단계 — SHARE_PUBLIC_URL)
|
||||
└─ /api/ ─(edge)─▶ <PROJECT_NAME>-backend :3000
|
||||
│ (app-network = 메일 엔진의 신뢰 대역)
|
||||
├─▶ db · redis
|
||||
└─▶ mail :8080 (JMAP·관리 API)
|
||||
├─▶ mail :8080 (JMAP·관리 API)
|
||||
│ └─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
|
||||
└─(clamav)─▶ clamav :3310 (악성 검사 — 대용량 첨부)
|
||||
▲ 서명 볼륨(읽기 전용)
|
||||
clamav-updater ─(clamav-egress)─▶ 서명 서버
|
||||
```
|
||||
|
||||
| 네트워크 | 붙는 컨테이너 | 비고 |
|
||||
@@ -57,6 +64,10 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
| `proxy` (external) | 외부 리버스 프록시 · frontend | 다른 프로젝트와 함께 쓸 수 있다. 운영 전용 (dev 오버라이드가 지운다) |
|
||||
| `<PROJECT_NAME>_edge` | frontend · backend | `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) 고정 — TRUST_PROXY 주소 목록용 |
|
||||
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
|
||||
| `<PROJECT_NAME>_mail-dns` | mail · mail-resolver | `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`) 고정 · `internal`(바깥 길 없음). 리졸버 주소 `MAIL_RESOLVER_IP`(기본 `172.16.73.254`)를 mail 의 `dns` 와 엔진 DNS 가 쓴다 |
|
||||
| `<PROJECT_NAME>_mail-dns-egress` | mail-resolver | 리졸버가 바깥 DNS 서버에 묻는 길. 서브넷은 고정하지 않는다(도커 기본 풀) — 서버의 사설망(VPC)과 겹치지 않는지 본다 |
|
||||
| `<PROJECT_NAME>_clamav` | backend · clamav | `internal`(바깥 길 없음) — 검사기는 바깥에서 온 파일을 여니 바깥 · 다른 컨테이너에 닿지 못하게. clamd 는 평문 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 고정하지 않는다 |
|
||||
| `<PROJECT_NAME>_clamav-egress` | clamav-updater | 서명 갱신(freshclam)이 서명 서버에 묻는 길 — updater 혼자 붙는다(백엔드 · 검사기와 잇지 않는다). 서브넷은 도커 기본 풀 — VPC 와 겹치지 않는지 본다 |
|
||||
|
||||
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
|
||||
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
|
||||
@@ -70,15 +81,49 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- 본문 한도: `/api/mail-attachments` = `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m), `/api/files` = `UPLOAD_MAX_SIZE_MB` + 1MB(기본 6m),
|
||||
**메일 발송 `POST /api/messages` 와 임시 저장 `/api/drafts` = 4m 고정**(서식 본문·긴 인용 — 계약 2절, 백엔드도 이 요청들만 4MB 파서.
|
||||
발송은 정확히 이 경로만이라 `/api/messages/{id}` 는 1m. 임시 저장은 `/api/drafts` 아래 전부 — 본문이 있는 것은 `POST`·`PUT` 뿐이다),
|
||||
**대용량 첨부의 조각 `PUT /api/file-uploads/{id}/chunks/{n}` = 17m 고정**(조각 16MiB 는 계약 상수 — 바꾸려면 계약 · 백엔드 · 화면을 함께, 11단계 —
|
||||
이 경로만 오류 로그를 `error` 부터 남긴다: 본문을 임시 파일에 받는다는 warn 이 조각마다 남았다),
|
||||
그 밖은 1m(백엔드 JSON 파서 한도). 앞의 두 값은 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다 (frontend 컨테이너만 다시 만들면 된다).
|
||||
JSON 한도(1m·4m)는 계약과 백엔드 코드의 고정값이라 env 로 바꾸지 않는다 — 바꾸려면 계약·백엔드 파서·`frontend/nginx.conf` 를 함께 고친다.
|
||||
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
|
||||
- 시간 제한: `/api/messages`·`/api/drafts`·`/api/message-deletions`·`/api/mail-events` · 대용량 조각 · 대용량 받기 · 휴대폰 앱 받기 90s, `/api/mail-attachments` 150s, 그 밖의 `/api` 는 nginx 기본 60s (`proxy_read_timeout`).
|
||||
응답 전체가 아니라 백엔드에서 "두 번 읽는 사이"의 시간이다 — 실시간 알림은 1시간 이어져도 25초마다 오는 ping 이 새로 세게 한다(머리글이 오지 않으면 90초에 504).
|
||||
- **실시간 알림(SSE) `/api/mail-events`**(계약 2절 "실시간 알림"): 이 경로만 버퍼링 · 캐시 · 압축을 끄고 `X-Accel-Buffering` 을 앞으로 넘긴다
|
||||
(외부 프록시가 nginx 계열이면 그쪽도 이 응답만 버퍼링을 끈다). 버퍼링을 켜 두면 머리글을 첫 이벤트까지, 백엔드가 청크 없이 닫아 끝내는 응답이면 끝까지 붙잡는다(2026-10-28 확인).
|
||||
머리글 · 오류 JSON · 접근 로그는 `/api` 와 같다. ⚠️ 서버의 ping 간격을 바꾸면 90s 도 함께 본다.
|
||||
열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다(이미지 기본값 워커마다 1024).
|
||||
열린 연결이 있으면 frontend 컨테이너를 멈출 때 nginx 가 기다리다 유예 시간을 다 쓰고 강제 종료된다 — 종료 코드 137 은 이 때문이고 오류가 아니다(화면은 다시 잇는다).
|
||||
- **대용량 첨부 (11단계 — 계약 4절 "공개 호스트" · "운영 확인")**: server 가 둘이다.
|
||||
- 포털(`default_server` · `server_name _`)과 공개 내려받기(`server_name` = `SHARE_PUBLIC_URL` 의 호스트 이름). `nginx-env.envsh` 가 그 호스트 이름을
|
||||
`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`.
|
||||
- 받기 `GET /api/public-links/{token}/files/…` · `GET /api/messages/{id}/large-files/…` 는 SSE 처럼 버퍼링 · 압축을 끄고 `X-Accel-Buffering` 을 넘긴다
|
||||
(기본값이면 받는 사람마다 1GB 까지 임시 파일에 받아 둔다). Range · If-Range 는 그대로 — 206 · 416 은 백엔드가 만든다.
|
||||
**휴대폰 앱(APK) 받기 `GET /api/app-releases/android/latest/file`**(2026-11-08 — 계약 1절 "휴대폰 앱 받기", 포털 · 로그인한 직원, 판마다 65MB 안팎 — 기기 종류 셋(ABI)이 한 파일에 든다)도 같은 줄들이다 —
|
||||
머리글을 대용량 받기와 같은 도우미(`backend/src/shared/utils/download-headers.ts` — `X-Accel-Buffering: no`)가 만든다. 정보 `GET /api/app-releases/android/latest`(JSON)는 받기 location 에 들지 않고,
|
||||
공개 내려받기 server 에서는 그 밖의 `/api` 처럼 404 `RES_001` 이다.
|
||||
머리글 버퍼는 `proxy_buffer_size 16k` + `proxy_busy_buffers_size 16k`(둘이 함께 — 하나만이면 `nginx -t` 가 실패) — 한글 255자 이름이면 머리글이 3.5KB 라 기본 4KB 에 아슬아슬하다.
|
||||
버퍼링을 끈 받기에서 `proxy_read_timeout` 은 돌지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 `send_timeout`(기본 60s)으로 끊는다 — 느려도 조금씩 받으면 이어진다(2026-10-30 확인).
|
||||
- **동시 연결 상한**(`limit_conn` — 받기가 연결 둘과 백엔드의 파일을 잡으므로): 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 서버 전체는 경로마다 따로 공개 400 · 앱 안 200
|
||||
(함께 세면 바깥 IP 몇십 개로 직원의 앱 안 받기까지 막힌다 — 2026-10-30 리뷰).
|
||||
휴대폰 앱 받기는 앱 안 받기의 그릇(`download_app_ip` 30 · `download_app_total` 200)을 함께 쓴다 — 둘 다 로그인한 직원의 받기라 막으려는 것이 같고, 그릇(받기 600 개 — `worker_connections` 셈)을 늘리지 않으려고.
|
||||
판이 나온 날 사무실 휴대폰이 한꺼번에 받다 대용량 받기와 합쳐 주소마다 30 에 닿으면 넘은 받기만 429 다(잠시 뒤 다시 — 처리 중인 요청만 센다).
|
||||
넘으면 429 `REQ_001`(JSON). 클라이언트 주소는 `X-Forwarded-For` 의 **마지막 칸**(외부 프록시가 이어 붙인 값 — 없으면 접속 주소)이다 — 앞 칸을 꾸며도 바뀌지 않는다.
|
||||
8080 에 프록시를 건너뛰고 바로 붙는 길이 있으면 주소마다 한도는 피한다(경로마다 서버 전체 한도는 그대로) — 그 길을 막는 것이 ACG · proxy 네트워크다. IPv6 는 /64 로 묶지 않는다.
|
||||
⚠️ **받기 location 은 `/api/` 블록 바로 안의 대소문자를 가리지 않는 정규식 하나씩이다**(공개 `~* ^/api/public-links/.+/files/`, 앱 안 `~* ^/api/messages/.+/large-files/`,
|
||||
휴대폰 앱 `~* ^/api/app-releases/.+/file` — 끝을 묶지 않는다: 끝의 `/` 같은 꼴도 상한을 타게) —
|
||||
백엔드 라우팅이 대소문자를 가리지 않던 때 `/FILES/` · `/api/Messages/…` 처럼 글자 하나로 상한과 `X-Accel-Buffering` 넘김을 비켜 갔다(2026-10-30 리뷰).
|
||||
같은 블록의 prefix 에 `^~` 가 있으면 그 아래 경로는 블록의 정규식을 건너뛰므로 `/api/messages` 는 `^~` 없이 둔다. id · 토큰 칸은 `.+`(nginx 는 `%2F` 를 풀어 location 을 고른다).
|
||||
포털이 닫는 공개 API 도 `~* ^/api/public-links(?:/|$)` 다. 백엔드도 라우팅이 대소문자를 가린다(두 겹 — [[nestjs]] "전역 HTTP 설정").
|
||||
거절은 접근 로그(429)로만 본다(`limit_conn_log_level info` — 오류 로그에 토큰째 요청 줄이 쏟아지지 않게).
|
||||
- 그릇: 워커마다 `worker_connections 4096`(이미지 기본 1024 — `frontend/Dockerfile` 이 바꾸고 바뀌었는지 grep 으로 확인해 못 바꾸면 빌드 실패) · compose 의 `ulimits nofile 8192`.
|
||||
- 캐시: `index.html`(직접 요청·`/`·SPA 폴백 모두)은 `Cache-Control: no-cache` — 배포 뒤 옛 index.html 이 지워진 해시 자산을 부르지 않게.
|
||||
`/assets/`(Vite 산출물 — 파일명에 내용 해시)만 `max-age=30일, public, immutable` 이다.
|
||||
해시가 없는 공개 파일(`frontend/public` → 루트, 예: `favicon.ico`)은 같은 이름으로 바뀌므로 `no-cache`(매번 재검증, 안 바뀌면 304).
|
||||
@@ -94,12 +139,23 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
실패(401·502 등)는 남긴다 (server 단계 `access_log … if=$log_access` 하나 — `/api/` 블록을 나누지 않는다).
|
||||
실시간 알림 `/api/mail-events` 는 성공도 남긴다 — 연결이 끝날 때 한 줄(브라우저마다 대략 1시간에 한 번)이고 `rt=` 가 이어진 시간이다.
|
||||
시작된 스트림은 어떻게 끝나도 200 이라, 끊김은 짧은 `rt=` 와 오류 로그의 `upstream prematurely closed connection` 으로 본다.
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다.
|
||||
**대용량 첨부의 내려받기 토큰도 가린다**(11단계) — 요청 줄 · Referer 의 `/s/<토큰>` · `/api/public-links/<토큰>…` 의 토큰 칸을 `***` 로(대소문자 무관, absolute-form 포함),
|
||||
그리고 경로 모양과 상관없이 토큰 모양(43자 base64url)으로도 — `//s/` · `/%73/` · `/api//public-links/` 같은 변형을 경로 모양만으로는 놓쳤다(2026-10-30 리뷰).
|
||||
토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 백엔드도 `maskUrlForLog()` 로 가린다.
|
||||
오류 로그(413·502·504 등)에는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다(오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다).
|
||||
`Origin`·`Sec-Fetch-Site`·`Idempotency-Key`·`Cookie` 는 그대로 넘긴다. `/api` 응답에는 화면용 보안 헤더를 붙이지 않는다(백엔드 helmet 이 정한다).
|
||||
- 백엔드 주소는 요청마다 도커 DNS 로 다시 풀어(`resolver`), 백엔드를 다시 만들어도 nginx 를 재시작할 필요가 없다.
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`, 백엔드 응답 없음 502·시간 초과 504
|
||||
(그리고 500·503) `SYS_001`. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
|
||||
- **nginx 가 직접 끝내는 `/api` 오류도 표준 실패 형식(JSON)이다** — 본문 한도 초과 413 `FILE_002`(공개 server 의 1m 넘는 본문 포함), 받기 동시 연결 초과 429 `REQ_001`(11단계),
|
||||
nginx 가 닫은 경로 404 `RES_001`(공개 server 의 그 밖 `/api` · 포털이 닫은 공개 API — 대소문자 변형도), 백엔드 응답 없음 502·시간 초과 504
|
||||
(그리고 500·503) `SYS_001`. 이름 있는 location 은 두 server 가 함께 넣는 `nginx-api-errors.conf` 에 있다 — `error_page` 를 하나라도 적은 location 은 위의 것을 물려받지 않아 셋을 다 적는다. `message` 는 비우고(클라이언트가 코드로 사전 문구를 쓴다) `traceId` 는 nginx 요청 ID 다 (응답 헤더 `X-Request-Id`·로그 `rid=` 와 같다).
|
||||
상태 코드는 원래 값 그대로다. 백엔드가 보낸 4xx·5xx 는 손대지 않는다 (`proxy_intercept_errors` 꺼짐).
|
||||
- **`/api/internal/` 도 바깥에서 404 다**(12단계 — 엔진만 부르는 웹훅 받는 경로 `POST /api/internal/mail-engine-events`). 스니펫 `nginx-internal-api-closed.conf`(템플릿 아님 — `/etc/nginx/snippets/`)를
|
||||
두 server 의 `/api/` 블록 안 **다른 정규식보다 먼저** 넣는다 — `~* ^/api/internal(?:[/\x5c]|$)` 라 대소문자 · `%XX`(`%5C` 포함) · 겹 `/` · `..` 변형도 걸린다. 엔진은 app-network 로 backend 에 바로 붙어 이 nginx 를 거치지 않는다
|
||||
(백엔드도 접속 주소가 `APP_NETWORK_SUBNET` 인지 · 본문 서명을 본다 — 세 겹). 운영 배포 때 frontend 이미지를 다시 만들어야 스니펫이 들어간다(`up -d --build`).
|
||||
- **요청 대상에 날 `\`(경로 — `?` 앞) · `#` · 0x80 이상 바이트가 있으면 두 server 모두 location 을 고르기 전에 400 `VAL_001` JSON 이다**(12단계 — server 단계 `if ($request_target_rejected)`).
|
||||
백엔드의 Express(parseurl)는 `#` · 0xA0 가 있으면 옛 `url.parse` 로 다시 읽어 `\` 를 `/` 로 바꿔, nginx 정규식이 닫은 `/api/internal/` · `/api/health` · 공개 API 가 백엔드에서 열렸다
|
||||
(2026-10-31 리뷰 — 백엔드도 같은 글자를 400 으로 막는다, 두 겹). 쿼리의 `\` 는 받는다(브라우저가 주소창 검색어의 `\` 를 그대로 보낼 수 있고, `url.parse` 는 `?` 앞의 `\` 만 바꾼다).
|
||||
닫는 정규식 셋(`/api/internal` · `/api/health` · 포털의 공개 API)도 `[/\x5c]` 로 넓혔다.
|
||||
- **`/api/health` 는 바깥에서 404 다** (`RES_001` JSON — 없는 경로와 같다). 실패하면 DB 오류 문자열 등 원인이 담기기 때문이다
|
||||
(대소문자·끝 `/` 도 막는다). 내부에서는 백엔드에 직접 묻는다:
|
||||
```bash
|
||||
@@ -111,25 +167,35 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
(CI 의 infra 잡이 같은 검사를 한다). 값을 바꿔 렌더링 결과를 보려면
|
||||
`docker run --rm -e PROJECT_NAME=x -e MAIL_ATTACHMENT_MAX_SIZE_MB=30 <tag> nginx -T | grep client_max_body_size`.
|
||||
`PROJECT_NAME`(또는 `NGINX_BACKEND_UPSTREAM`) 없이 띄우면 일부러 기동을 멈춘다.
|
||||
공개 호스트를 가를 때와 아닐 때 설정이 다르다 — 두 꼴을 다 본다: `-e SHARE_PUBLIC_URL=https://files.example.kr -e CORS_ORIGIN=https://portal.example.kr` 와 둘 다 없이.
|
||||
- **템플릿 값이 잘못되면 frontend 가 뜨지 않는다** (`frontend/nginx-env.envsh`, fail-fast) — `docker logs <PROJECT_NAME>-frontend` 의 `nginx-env.envsh:` 줄을 본다.
|
||||
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
|
||||
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
|
||||
- `SHARE_PUBLIC_URL`(11단계): 빈 값(공개 호스트를 가르지 않음) 또는 출처 `http(s)://호스트[:포트]` 만 — 경로 · 끝의 `/` · 끝 점(nginx 의 `$host` 는 끝 점을 떼어 백엔드 판정과 어긋난다) ·
|
||||
점 · 하이픈으로 시작하거나 끝나는 이름 · `;` · 공백 · `@` · 대괄호를 거부한다(`server_name` 에 지시어를 끼워 넣지 못하게). 백엔드 env 검사도 같은 값을 거절한다 — 한쪽만 뜨는 반쪽 배포가 되지 않게
|
||||
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
|
||||
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
|
||||
frontend 는 TLS 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
|
||||
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
|
||||
`.env.production` 의 `COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml` 로 켜서 운영 명령은 그대로 쓴다. 공인 IP 를 사설 IP 로 NAT 하는 곳에서는
|
||||
게시 주소로 바깥을 막지 못하므로 8080 은 방화벽(NCP ACG)이 프록시 서버의 사설 IP 에서만 받게 한다 — 절차 · NPM 설정은 mail/README.md "리버스 프록시가 다른 서버에 있을 때"):
|
||||
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
|
||||
(로그인 실패 한도가 IP 기준이다).
|
||||
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
|
||||
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
|
||||
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
|
||||
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
|
||||
**프록시가 다른 서버에 있으면** 주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다).
|
||||
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
|
||||
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
|
||||
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`, 메일을 열 때마다 부르는 같은 대화 `GET /api/messages/{id}/conversation` — 14단계)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
|
||||
외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
|
||||
- **백엔드의 바깥 연결**(운영 확인 항목, 9단계): 외부 이미지 중계는 backend 컨테이너가 바깥 80 · 443 으로 나가 이미지를 받아 온다 — 호스트 방화벽 · 사내 프록시가 막으면
|
||||
이미지 자리가 비어 보인다(다른 기능은 영향 없음). 사설 · 내부 주소로는 백엔드가 스스로 나가지 않는다(SSRF 막기 — 계약 2절 "외부 이미지 보기").
|
||||
그 막기는 사설 대역에 기댄다 — `APP_NETWORK_SUBNET` · `EDGE_NETWORK_SUBNET` 은 사설 대역으로 둔다(mail setup 은 앱 네트워크만 검사한다).
|
||||
서버 자신의 공인 주소로 풀리는 이름은 막지 않는다(공인 주소라서) — 그 요청은 호스트의 80 · 443 에 닿으므로, 같은 외부 프록시 뒤에 "내부 주소면 허용"하는 다른 사이트를 두지 않는다
|
||||
(메일 엔진의 443 은 setup 이 403 으로 막고, 중계는 응답이 그림일 때만 돌려준다 — 2026-10-29 리뷰).
|
||||
**앱 새 메일 알림(앱 1단계 — FCM)** 도 같은 길로 나간다: backend → `oauth2.googleapis.com:443`(서비스 계정 JWT → access token) · `fcm.googleapis.com:443`(보내기).
|
||||
막히면 휴대폰 알림만 오지 않는다(로그 warn · error — 다른 기능은 영향 없음). **서버 시계가 맞아야 한다**(NTP — `timedatectl` 의 "System clock synchronized: yes") — JWT 의 `iat` 가 틀리면 Google 이 토큰 교환을 거절한다.
|
||||
키(`FCM_SERVICE_ACCOUNT_KEY` — 서비스 계정 JSON 을 통째로 base64 한 줄)는 `backend/.env.{APP_ENV}` 에 둔다(compose 는 바꿀 것이 없다 — `env_file`). 비면 알림만 끄고 뜬다 — 넣었으면 기동 로그 "앱 새 메일 알림: 켬 (Firebase 프로젝트 …)"를 본다.
|
||||
- **본문 한도는 `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB 이상**, 읽기 시간 제한은 150s 이상. 작으면 백엔드에 닿기 전에 413 이 나고
|
||||
사용자에게는 `FILE_002` 로 보인다 (프론트 nginx 는 자동으로 맞지만 외부 프록시는 직접 맞춘다).
|
||||
- **실시간 알림 `/api/mail-events` 를 흘려보낸다** (운영 확인 항목, 계약 2절 "실시간 알림"):
|
||||
@@ -146,12 +212,26 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- **접근 로그에 쿼리를 남기지 않는다** (운영 확인 항목). 화면 주소(`/mail/search?q=…` · `?draft=<id>`)와 API 주소(`/api/messages?q=…` ·
|
||||
`/api/recipient-suggestions?q=…`)에 검색어 · 임시 저장 id 가 들어간다 — 프론트 nginx 와 같은 기준([[logging]]).
|
||||
화면의 `Referrer-Policy: strict-origin` 덕분에 Referer 로는 새지 않지만, 새로고침 · 직접 들어온 요청 줄은 외부 프록시가 그대로 받는다.
|
||||
- **대용량 첨부의 공개 내려받기 호스트를 잇는다** (운영 확인 항목, 11단계 — 절차 · NPM 칸은 mail/README.md "대용량 첨부 (11단계 — 인프라)"):
|
||||
- DNS(`files.` A → 외부 프록시 서버) · TLS 인증서 · Proxy Host 를 포털과 **같은 frontend:8080** 으로 — `Host` 를 바꾸지 않고 넘긴다(nginx 와 백엔드가 Host 로 가른다).
|
||||
`X-Forwarded-For` 는 포털과 같게 이어 붙인다(받기 동시 연결 상한이 마지막 칸을 센다).
|
||||
- **이 호스트는 접근 로그를 끈다** — 경로에 내려받기 토큰이 있다(우리 nginx 는 가리지만 외부 프록시는 원문을 남긴다). 오류 로그에도 토큰째 남으니 볼 수 있는 사람을 한정한다.
|
||||
- 받기 응답은 흘려보낸다 — nginx 계열은 우리가 넘기는 `X-Accel-Buffering: no` 를 따른다(SSE 와 같다). 2GB 까지 이어지므로 응답 **전체** 시간 상한이 있으면 없애거나 넉넉히.
|
||||
- 포털 호스트의 본문 한도는 조각 올리기 때문에 **17m 이상**이어야 한다(위 첨부 한도 21m 이면 된다).
|
||||
- **두 호스트 모두 머리글 버퍼를 키운다** — `proxy_buffer_size 16k;` 와 `proxy_busy_buffers_size 16k;` 를 함께(NPM 2.16.0 은 기본 4k · 한 줄만 넣으면 `nginx -t` 가 실패해 그 Proxy Host 가 Offline).
|
||||
긴 한글 이름 파일의 받기 응답 머리글(3.5KB)이 4k 를 넘으면 그 받기만 502 다.
|
||||
- 업로드 중 임시 디스크: 백엔드는 첨부를 `/tmp/arasystem-mail-uploads`(컨테이너 쓰기 계층)에, nginx 는 요청 본문을 자기 임시 폴더에 받는다.
|
||||
필요한 공간은 동시 업로드 수 × 20MB 정도. backend 를 `read_only` 로 바꾸면 그 경로에 **tmpfs 가 아닌 볼륨**을 붙인다 (tmpfs 는 메모리 한도에 잡힌다).
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다). 호스트 디렉터리(`./data/uploads`)를 쓰지 않는 이유 —
|
||||
- **업로드 파일은 named volume `<PROJECT_NAME>_uploads` 에 쌓인다** (`/api/files`, 메일 서명 그림은 그 안의 `mail-signatures/` — 서명 표와 함께 백업한다,
|
||||
휴대폰 앱 APK 는 그 안의 `app-releases/` — 판마다 65MB 안팎 · 지우는 명령이 없어 올린 판이 모두 남는다 · 매일 백업에 든다). 호스트 디렉터리(`./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 에 있다(서버를 되살리면 그 전의 링크는 파일이 없어 받지 못한다).
|
||||
디스크는 백엔드가 올리기를 만들 때 여유(`FILE_STORAGE_MIN_FREE_GB`)를 본다 — 같은 디스크를 악성 검사의 임시 파일(아래 "대용량 첨부" 절)이 함께 쓴다.
|
||||
악성 검사 서명 볼륨 `<PROJECT_NAME>_clamav-db`(110MB 남짓)도 백업하지 않는다 — 지워도 clamav-updater 가 다시 받는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -254,9 +334,21 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
같은 PC 에 러너가 여럿이면 `RUNNER_NAME` 을 겹치지 않게 짓는다(이 저장소는 `arasystem-runner`).
|
||||
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
|
||||
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t` · 메일 리졸버 `unbound-checkconf` — compose 의 이미지 · 사용자 101:102)은 잡 컨테이너 안에서 docker 를 쓴다.
|
||||
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
|
||||
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
|
||||
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
|
||||
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
|
||||
11단계부터는 nginx 를 두 꼴(공개 호스트를 가를 때 · 아닐 때)로 `nginx -t` 하고 렌더링 결과(server 이름 · 포털이 닫는지 · 받기 영역 400 · 200)와 틀린 `SHARE_PUBLIC_URL` 의 기동 거부를 본다.
|
||||
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%2F` · 겹 `/` 변형이 모두 상한(429 JSON) · `X-Accel-Buffering` 을 타는지 실제 요청으로 본다
|
||||
(휴대폰 앱 받기 `…/file` 도 — 앱 안 받기와 그릇을 함께 쓰는지 · 공개 호스트는 404 인지 · 정보 경로 `…/latest` 는 타지 않는지. "nginx 설정 검사"는 `limit_conn download_app_*` 가 두 꼴 모두 둘씩인지 센다).
|
||||
"닫는 경로 시험"(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 규칙).
|
||||
|
||||
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
|
||||
|
||||
@@ -285,7 +377,7 @@ dc --profile redis up -d
|
||||
|
||||
---
|
||||
|
||||
## 메일 (Stalwart · Mailpit) — profile `mail`
|
||||
## 메일 (Stalwart · unbound · Mailpit) — profile `mail`
|
||||
|
||||
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
|
||||
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
|
||||
@@ -310,18 +402,104 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의
|
||||
20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29).
|
||||
더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도").
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
- setup 은 SMTP 로 받는 메일 한 통의 크기(`x:MtaStageData` 의 `maxMessageSize`, 기본 100MiB)를 50MiB 로 낮춘다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 들어
|
||||
메일 컨테이너 1g 로는 바깥 100MB 한 통이 한도에 닿았다(2026-11-03 개발 984/1024MiB · 운영은 106MB IMAP APPEND 로 OOM). IMAP 요청 · 메일 크기 한도(`x:Imap` · `x:Email`)는
|
||||
이전(imapsync) 때 잠시 올릴 수 있어 바꾸지 않고, 기본보다 크면 `[주의]` 만 낸다 (mail/README.md "엔진 한도" · "큰 메일").
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소와 리졸버 네트워크(서브넷 · 구성원 · 리졸버 주소 · mail 의 `dns`)를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
|
||||
- **메일 전용 DNS 리졸버** `mail-resolver`(unbound — 설정 `mail/resolver/unbound.conf`): 엔진의 DNS 조회(스팸 차단 목록 · SPF · DKIM · DMARC · MX · PTR · TLSA)를
|
||||
공용 · ISP DNS 대신 이 서버에서 직접 푼다 — Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다. DNSSEC 을 검증해 엔진의 DANE 이 켜지고, 조회 이름은 로그에 남기지 않는다.
|
||||
사설 · 내부 주소의 DNS 서버에는 묻지 않는다(`do-not-query-address` — 바깥 도메인이 이름 서버 주소를 사설 IP 로 두어 VPC 안을 두드리게 하지 못하게).
|
||||
setup 이 엔진의 `x:DnsResolver` 를 맞춘다 — 운영은 리졸버에 바로(Custom · UDP+TCP), 개발은 도커 DNS 를 거쳐(System — `mailpit` 같은 도커 이름 때문).
|
||||
사전 확인은 UDP 는 루트 SOA, TCP 는 루트 DNSKEY 를 DNSSEC 과 함께 묻는다 — UDP 로 DNSKEY 를 물으면 루트 키 교체 기간에 답이 잘려(TC) 멀쩡한 리졸버를 실패로 본다(2026-10-30 리뷰).
|
||||
mail 은 리졸버가 healthy 일 때 뜨지만(`depends_on … required: false`) 리졸버가 unhealthy 거나 뜨지 못하면 경고만 내고 mail 을 띄운다 — 리졸버 설정 오류 하나로
|
||||
`up -d mail` 이 옛 mail 을 지운 채 끝나 메일 전체가 서는 것을 막는다(2026-10-30 개발에서 확인). **서버 재부팅 때는 도커가 이 차례를 지키지 않는다** — 재부팅 뒤 `restart mail` 하고 setup 으로 `[확인]` 을 본다(먼저 뜨면 DANE 만 꺼진다).
|
||||
리졸버가 바깥에 닿지 못하는 동안은 **엔진이 설정 리로드를 통째로 거절한다**(스팸 필터의 Pyzor 주소 `public.pyzor.org` 를 같은 리졸버로 풀다 실패) — setup · 관리 화면의 리로드가 모두 막힌다.
|
||||
비상 길은 관리 화면에서 엔진 DNS 를 공용 DNS 로 저장한 뒤 리로드가 아니라 `restart mail`(엔진 DNS 를 System 으로 돌려도 mail 의 `dns:` 가 같은 리졸버라 소용없다) — mail/README.md "리졸버 장애 때".
|
||||
Spamhaus 두 항목(ZEN · DBL)은 `MAIL_SPAMHAUS_DQS_KEY` 가 있으면 시험 항목이 나오는지 본 뒤 DQS(`<키>.zen.dq.spamhaus.net` 등)로 켜고, 없으면 끈다(공개 미러는 소규모 · 비상업 용도라 쓰지 않는다).
|
||||
키는 출력하지 않지만(setup 의 모든 출력이 키를 가린다) 엔진의 스팸 필터 설정에 그대로 저장된다(관리 화면 · 관리 API · 메일 볼륨 백업). 체험 키는 기간이 끝나면 답이 멈추고
|
||||
엔진은 표시 없이 지나간다(fail-open) — 다음 setup 이 끄고 종료 1 로 알리므로 운영은 setup 을 주기적으로 돌려 본다 — mail/README.md "DNS 리졸버와 스팸 차단 목록".
|
||||
- setup 은 엔진 웹훅(`x:WebHook` 하나 — 12단계 메일 프로그램 로그인 기록)을 `MAIL_WEBHOOK_SECRET` 으로 맞춘다: url `http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`(setup 이 `BACKEND_PORT` 도 읽는다),
|
||||
이벤트 `auth.success` · `auth.failed` · `auth.mfa-required` 만(`include` — 기본은 모든 이벤트), `level: trace`, throttle 1초 · discardAfter 5분.
|
||||
0.16.27 의 웹훅에는 이름 칸이 없어 머리글 `X-Arasystem-Setup: [setup.mjs] key=<소금 24hex>.<지문 24hex>` 로 찾는다 — 엔진이 서명 키를 `****` 로만 돌려주므로 키가 바뀌었는지도 이 지문으로 안다.
|
||||
소금은 쓸 때마다 새로 뽑는다(옛 모양은 한 번 맞춘다). 백엔드는 이 머리글을 쓰지 않는다. 키는 사람이 짓지 않는다(`openssl rand -hex 24`).
|
||||
리로드 뒤 메일 컨테이너에서 서명한 빈 묶음으로 받는 곳을 확인한다 — 백엔드 쪽(받는 곳 · 접속 주소 · 키)만 본다(관리 화면에서 서명 키만 바꾼 것은 알아채지 못한다).
|
||||
401 · 404 는 운영에서 실패(개발은 `[주의]`), 닿지 못함 · 5xx 는 `[주의]` 다. 이번 실행에서 키를 바꿨으면 401 을 `[주의]` 로 낮추고 `up -d backend` 뒤 setup 한 번 더를 안내한다
|
||||
(404 안내는 운영 `up -d --build backend frontend` · 개발 `restart backend`). 키를 바꿀 때는 backend 를 먼저 띄우고 setup 을 돌리는 차례를 권한다.
|
||||
운영은 키가 비거나 예시 값이면 아무것도 바꾸기 전에 멈춘다. 개발은 키가 비면 `[주의]` 와 함께 있던 웹훅을 끄고, 예시 값이면 `[주의]` 만 낸다.
|
||||
키는 엔진 설정에 그대로 저장된다(메일 볼륨 백업에 있다 — Spamhaus 키와 같은 성격). 402 를 "비밀번호 맞음"으로 보는 것은 테스트 직원(웹메일에서 2단계를 켰을 수 있다)뿐이다 —
|
||||
게이트웨이가 402 면(OTP 를 켜면 백엔드의 대리 접근이 모두 막혀 웹메일 전체가 선다) 아무것도 바꾸기 전에 멈추고, 관리자도 그렇다.
|
||||
엔진 DNS 가 Custom(운영 — 리졸버)이어도 웹훅은 `backend` 를 푼다 — 메일 컨테이너의 resolv.conf 가 도커 DNS(127.0.0.11)다(2026-10-31 개발에서 Custom 으로 바꿔 확인) (mail/README.md "계정 보안 (12단계 — 인프라)").
|
||||
- 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 의 `--automap` 이 mailcow 의 Trash · Junk 를 이 둘로 맞춘다(2026-11-02 개발 확인 — 따로 생긴 폴더가 있을 때만 `--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 에 넘긴다).
|
||||
**bootstrap 전에만 정할 수 있다** — 바꾼 값이 엔진의 관리자와 다르면 setup 이 멈춘다. 다른 관리자 계정은 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다.
|
||||
- 사내 목록 계정 `<MAIL_DIRECTORY_LOCAL_PART>@도메인`(기본 `mail-directory`, compose 가 backend 에 넘긴다) — 백엔드가 엔진의 계정 목록에서 이름·주소만 읽는 비밀번호 없는 계정(게이트웨이 대리 접근으로만). 엔진 관리자는 그 권한을 내줄 수 없어 setup 이 **복구 관리자로 돌 때만** 만든다(개발은 늘, 운영은 `--recovery` — 이미 운영 중이면 한 번 더). 없으면 `[주의]` 만 내고 백엔드는 로그인한 직원 기록으로 대신한다 (mail/README.md "사내 목록 계정").
|
||||
- **파일럿 공존**(10단계 — mail/README.md "파일럿 공존"): 루트 env `MAIL_PILOT_LEGACY_HOST`(옛 서버 mailcow 주소)·`MAIL_PILOT_LEGACY_IPS`(그 서버가 보내는 IP)를 넣으면
|
||||
setup 이 새 서버에 없는 사내 주소를 옛 서버로 중계하게 한다. 켜는 차례는 막는 것부터 — 시범 표시(옛 서버 IP 의 자동 차단 예외 — 아래 식들의 주인 표시) →
|
||||
받는 쪽 가드(인증 없는 모르는 사내 주소 550)·위조 막기·옛 서버 IP 의 검사 · 한도 예외 → 리로드 → **켜기 전 가드 확인** → 경로(리로드) →
|
||||
도메인의 `allowRelaying`(저장 즉시 먹어 가드보다 먼저 켜면 열린 중계가 된다) → 켠 뒤 확인. 끌 때는 거꾸로(`allowRelaying` 먼저) — 운영은 값을 비우고 `--end-pilot` 일 때만.
|
||||
**가드 없는 relay 를 setup 이 만들지 않는다** — 가드 확인은 "2초 안에 550"이다(가드 거절은 곧바로 · 엔진 자체 거절은 `waitOnFail` 5초 뒤라 응답 시간으로 가른다 — 그래서 `waitOnFail` 은 4초 이상).
|
||||
켜기 전에 아니면 켜지 않고, 켠 뒤에 아니면 relay 를 바로 다시 끈다(둘 다 종료 코드 1). 끄지 못하면 "끄지 못했습니다"로 멈춘다 — 이미 켜진 relay 를 처음 확인할 때(0단계)는
|
||||
멈추기 전에 RCPT 가드만 다시 걸고 그 결과를 싣는다. "대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다"면 열린 relay 는 아니고(relay 는 켜진 채),
|
||||
"열려 있습니다 · 열려 있을 수 있습니다"면 관리 화면에서 그 도메인의 relay 허용을 바로 끈다.
|
||||
가드는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 뺀다 — 켜져 있는데 setup 의 `legacy` 경로가 없으면
|
||||
끄지 않고 가드도 남긴 채 종료 코드 1(요약 `끝내지 못함`, 시범 설정이 하나도 없으면 `시범 설정 없음 · allowRelaying 켜짐`).
|
||||
개발은 셸로 한 번 시험한다(`MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` — 다음 평소 실행이 끈다.
|
||||
엔진 로그에서 개발 외부 경로 `mailpit` 과 가르려면 컨테이너 이름 `<PROJECT_NAME>-mailpit:1025` 를 준다).
|
||||
운영 확인 항목: 새 호스트 → mailcow **25** 가 열려 있을 것(운영은 25 만 받는다), 파일럿 동안 mx1 에 AAAA 를 두지 않을 것(SPF 는 ip4 만), mailcow 가 보내는 IP 가 목록과 같을 것(빗나가면 동료 메일이 `550 5.7.1`),
|
||||
그 IP 로 사람이 만든 자동 차단 예외(`x:AllowedIp`) · 검사 예외(SPF · DMARC 등) · IP 한도 예외가 없을 것(setup 이 시범 표시를 붙여 만든다 — 있으면 멈춘다),
|
||||
새 DKIM TXT 를 **켜기 전에** DNS 에 넣을 것(운영은 처음 켤 때 TXT 가 없거나 다르면 켜지 않는다),
|
||||
끄기 · 되돌리기 전에 새 서버 대기열에 동료 앞 메일이 남지 않았을 것(끈 뒤에는 로컬 실패 → 반송).
|
||||
개발의 인증 발송 확인(465)은 백엔드 컨테이너의 node 로 돈다(메일 이미지에는 node 가 없다 — 비밀번호는 표준 입력으로 넘기고 출력하지 않는다).
|
||||
- ⚠️ **게이트웨이 비밀번호 = 관리자급 비밀.** Stalwart 0.16 은 대리 접근 대상을 제한하지 못해, 신뢰 대역 안에서는 게이트웨이로 관리자 계정도
|
||||
대리 접근된다 (setup 이 매번 `[주의]` 로 보여 준다). 막는 일은 백엔드가 한다 — mail/README.md "보안".
|
||||
|
||||
---
|
||||
|
||||
## 대용량 첨부 — 악성 검사기 (ClamAV) · 운영은 늘, 개발은 profile `clamav`
|
||||
|
||||
백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (계약 4절 "악성 검사", `backend/src/modules/file-storage/clamav-client.ts`).
|
||||
설정은 `mail/clamav/`, 판정 실측표 · 메모리 예산 · 문제 해결은 [`mail/README.md`](../../../mail/README.md) "대용량 첨부 (11단계 — 인프라)".
|
||||
|
||||
- **컨테이너 둘**: `clamav`(clamd — 백엔드와만 닿는 internal 네트워크 하나, 서명 볼륨은 읽기 전용)와 `clamav-updater`(freshclam — 자기 바깥 길에만).
|
||||
바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다 — 파일 해석기에 구멍이 나도(2026년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게.
|
||||
새 서명은 clamd 가 10분마다 스스로 본다(`SelfCheck 600` — updater 가 알려 주지 않는다). clamd 는 서명이 생긴 뒤 뜬다(처음 기동은 받는 1분 남짓을 기다린다).
|
||||
- **개발**: 기본은 꺼져 있고 `CLAMAV_HOST` 가 비면 백엔드는 검사하지 않고 다 올리자마자 "검사하지 못함"이다. 켜려면 루트 env 에
|
||||
`COMPOSE_PROFILES=mail,clamav` 와 `CLAMAV_HOST=clamav` **둘 다**(clamd 1GB 남짓 · 처음에 서명 110MB). 운영은 `CLAMAV_HOST` 가 비면 백엔드가 뜨지 않는다.
|
||||
- **설정 `mail/clamav/clamd.conf`** — 이미지 기본 설정을 통째로 바꾼다(바꾼 뒤 `up -d --force-recreate clamav`):
|
||||
- `StreamMaxLength` = `MaxFileSize` = 2047M(한 파일 한도의 최대값 — 넘는 스트림은 `INSTREAM size limit exceeded. ERROR` → 백엔드는 설정 오류로 error), `MaxScanSize` 4094M,
|
||||
`MaxScanTime` 5분(1.4 에서는 ZIP 에만 걸린다 — 7z · tar.gz 는 MaxScanSize 까지 시간 상한 없이 푼다).
|
||||
- **`AlertExceedsMax yes` · `AlertEncrypted yes` 는 끄지 않는다** — 끄면 한도를 넘거나 암호가 걸린 파일이 `stream: OK`(깨끗함)로 온다(확인함). 백엔드는 이 두 휴리스틱을 "검사하지 못함"으로 가른다.
|
||||
- `ConcurrentDatabaseReload no` — 켜면 새 서명을 다 읽을 때까지 두 벌(2GB 남짓)을 들어 한도 2g 를 넘는다. 대신 다시 읽는 30초 안팎 동안 검사 · PING 이 기다린다(백엔드는 다시 한다).
|
||||
- 받은 스트림 · 푼 내용은 컨테이너 쓰기 계층의 `/tmp` 에 잠시 쓴다 — 동시 검사 2 × (2GB + 푼 내용) — tmpfs 로 바꾸지 않는다(메모리 한도에 잡힌다).
|
||||
검사 중에 죽으면(OOM 등) 그 임시 파일이 쓰기 계층에 남아 재시작마다 쌓였다(2026-10-30 확인) — 그래서 entrypoint 가 `sh -c 'rm -rf /tmp/clamav-*; exec clamd …'` 다.
|
||||
- **자원**: clamd 메모리 1GB 안팎(서명 363만 개 — 서명이 늘면 는다) · 한도 2g, CPU 는 검사 하나가 코어 하나(2,047MB 에 5분 안팎 — 개발 PC) · `cpus 2.0` · `cpu_shares 512`
|
||||
(서버가 2 vCPU 보다 적으면 낮춘다). updater 는 평소 수십 MB, 처음 받을 때 0.7GB 남짓 · 한도 1g.
|
||||
- **상태**: clamav 의 healthcheck 는 PING(다시 읽는 동안 세 번까지 기다린다), updater 는 daily 서명이 이틀 안에 바뀌었는지 — 갱신이 이틀 넘게 멈추면 unhealthy(검사는 옛 서명으로 이어 간다).
|
||||
- 이미지는 Alpine 판이라 **linux/amd64 만** 있다 — ARM 서버면 `_base-debian`(같은 판)을 쓴다. 1.4 LTS 가 끝나면(2027-08 무렵) 서명을 받지 못한다 — 그 전에 다음 LTS 로.
|
||||
|
||||
---
|
||||
|
||||
## 백업 · 감시 (호스트 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`(mail/README "백업 › 복원" 이 기준).
|
||||
- **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`)를 쓰지 않는다.** 그날의 최신이 들어오면 호스트마다 버전이 달라지고,
|
||||
@@ -331,9 +509,17 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| --- | --- | --- |
|
||||
| Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" |
|
||||
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
|
||||
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
|
||||
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. 앱 알림의 N 은 `Email/query` 의 `after` 가 그 초를 넣지 않는 것에 맞춰 한 초 앞을 넘긴다(`jmap-mail.provider.ts` 의 `inboxUnreadSinceFilter` — 2026-11-08 확인) — 올리면 이것도 다시 본다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
|
||||
| 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` |
|
||||
| imapsync (전환 때 서버 B 호스트에서 — 2026-11-02) | `gilleslamiral/imapsync:2.326@sha256:ece980cb…` | `mail/README.md` 「imapsync」 절의 명령. 저자 공식 이미지 · amd64 만 · 비-root(`nobody`). 인증서를 기본으로 확인하지 않는다(`SSL_verify_mode=0`) — 운영 명령은 `--sslargs1/2 SSL_verify_mode=1` 꼴(README) |
|
||||
| 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` 값이라 아직 떠 있는 태그다 |
|
||||
| Flutter SDK (CI · 개발 PC — 이미지가 아니다) | `3.41.9` stable (Dart 3.11.5 · 2026-04-30) | `.github/workflows/ci-flutter.yml` 의 `flutter-version`, `flutter/pubspec.yaml` 의 `environment.sdk`(Dart 하한) · `intl`(새 SDK 의 flutter_localizations 가 고정한 판), `flutter/README.md`, [[flutter]] "Flutter 판", 이 문서. 개발 PC 의 Flutter(`C:\src\flutter`)는 다른 프로젝트도 함께 쓴다 — 같은 마이너의 패치 3.41.9 는 2026-11-06 사용자 확인 뒤 올렸고, 마이너(3.47 등)를 올리는 것은 사용자가 정한다(올리면 개발 PC 와 CI 를 함께) |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
새 태그가 있는지와 digest 는 `docker buildx imagetools inspect <이미지>:<태그>` 로 확인한다 (2026-10-27: Stalwart `v0.16`·`v0.16.27`, nginx `stable-alpine`·`1.30.6-alpine3.24` 가 각각 같은 digest).
|
||||
@@ -354,6 +540,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` 만으로는 **예전 볼륨이 그대로 재사용된다.**
|
||||
@@ -392,6 +580,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
`nest-cli.json` 의 `deleteOutDir` 는 마운트 지점을 지우려다 EBUSY 로 죽으므로, dev `command` 는 기동 때 볼륨 내용만 비우고
|
||||
`deleteOutDir` 만 끈 설정 사본으로 `start:dev` 를 돌린다 (`-V` 로 재생성하면 이미지의 산출물로 다시 채워진다).
|
||||
- **dev backend 메모리 한도는 2g** — watch 컴파일과 타입 검사가 함께 돌아 base 의 1g 에서 OOM 으로 죽었다 (운영은 1g 그대로).
|
||||
- **db 메모리 한도는 1g · 공유 버퍼(`shared_buffers`) 256MB** (14단계 — 메일 검색 색인의 찾기는 표 · 색인이 메모리에 있어야 1초 안이다, 캐시가 비면 3~8배).
|
||||
공유 버퍼는 compose 의 db `command`(`postgres -c …` — 명령줄 값이 데이터 폴더의 설정보다 이긴다)이고 개발도 같은 값이다(dev 오버라이드가 덮어쓰지 않는다).
|
||||
8GB 서버의 한도 합 · 운영에 올리는 차례는 mail/README.md "메모리 예산".
|
||||
- **dev backend 는 컨테이너를 기동할 때 한 번 `migration:run` 을 한다** (`dc down -v` 등으로 DB 를 새로 만든 뒤에도 바로 뜨게).
|
||||
watch 재시작은 이 단계를 다시 돌지 않는다 — 그래서 생성만 하고 검토 전인 마이그레이션이 파일을 저장할 때마다 적용되지는 않는다.
|
||||
⚠️ 다만 **컨테이너를 다시 띄우면** 그때 있는 마이그레이션이 적용된다. 마이그레이션을 고치는 중(revert → 수정 → run)에는
|
||||
@@ -404,12 +595,15 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
|
||||
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
|
||||
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
|
||||
- 메일 리졸버 네트워크 `mail-dns` 도 서브넷(`MAIL_RESOLVER_SUBNET`, 기본 `172.16.73.0/24`, 사설 대역 `/24`~`/29`)과 리졸버 주소(`MAIL_RESOLVER_IP`, 끝 쪽 — 도커는 mail 에 앞쪽부터 준다)를 고정한다.
|
||||
mail 의 `dns` 와 엔진 설정이 IP 로만 받기 때문이다 — 바꾸면 둘을 함께 바꾸고 setup 을 다시 실행한다. 리졸버만 붙는 `mail-dns-egress` 는 고정하지 않아 도커 기본 풀에서 받는다 —
|
||||
운영 서버의 사설망(VPC)이 그 대역(172.17~31 · 192.168)이면 겹치지 않는지 본다.
|
||||
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`dc down` 후 `dc up -d`** (또는 `dc up -d --force-recreate`)로 전부 다시 만든다.
|
||||
그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어
|
||||
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
|
||||
- **env 파일**: 실행 전 아래 두 파일이 모두 있어야 한다. 없으면 `*.example` 을 복사해 생성한다.
|
||||
- 루트 `.env.{APP_ENV}` — 설정 SSOT ([[env-structure]])
|
||||
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다**
|
||||
- `backend/.env.{APP_ENV}` — compose `env_file` 대상이라 **비어 있어도 존재해야 한다** (백엔드 고유 시크릿 — `JWT_SECRET`, 앱 알림을 쓰면 `FCM_SERVICE_ACCOUNT_KEY` — 매일 백업에 함께 든다)
|
||||
복사 후 `change-me-*` placeholder 값을 반드시 교체한다 ([[security]]).
|
||||
- **Graceful shutdown**: backend 는 `init: true`(tini)로 PID 1 을 두어 SIGTERM 이 애플리케이션까지 전달되게 하고,
|
||||
`stop_grace_period: 30s` 동안 처리 중인 요청을 마치도록 한다.
|
||||
@@ -424,9 +618,18 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
|
||||
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
|
||||
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
|
||||
운영 frontend 도 열지 않는다 — 프록시가 다른 서버에 있을 때만 8080 을 사설 IP 에 연다(`docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 사설 IPv4:포트가 아니면 frontend 를 띄우지 않는다).
|
||||
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
|
||||
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
|
||||
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
|
||||
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
|
||||
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
|
||||
- **메일 리졸버**: 이미지가 root 로 떠서 권한을 내리는 꼴이라 처음부터 `user: "101:102"`(이미지의 unbound 사용자) · `cap_drop: ALL` · `no-new-privileges` 로 띄운다.
|
||||
53 에 붙는 것은 도커가 컨테이너마다 `net.ipv4.ip_unprivileged_port_start=0` 으로 두기 때문이다. 호스트 포트는 어떤 환경에서도 열지 않는다(열린 리졸버가 되지 않게).
|
||||
- **악성 검사기(clamav · clamav-updater)**: 이미지의 `/init` 은 root 로 소유자를 바꾸고 둘을 함께 띄운다 — 쓰지 않고 `entrypoint` 로 clamd · freshclam 을 바로,
|
||||
`user: "100:101"`(이미지의 clamav 사용자) · `cap_drop: ALL` · `no-new-privileges` · `init: true`. 설정 파일은 그 사용자가 읽을 수 있어야 한다(644). 호스트 포트는 어떤 환경에서도 열지 않는다.
|
||||
- **Git Bash 에서 컨테이너 안 경로를 인자로 주지 않는다** — MSYS 가 `/dev/null` 같은 인자를 Windows 경로로 바꿔 `docker exec … wget -O /dev/null` 이
|
||||
저장소에 `nul` 파일을 만든다(2026-10-30). `MSYS_NO_PATHCONV=1` 을 앞에 붙이거나 PowerShell 로 돌린다.
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`
|
||||
(프록시가 다른 서버에 있으면 `.env.production` 의 `COMPOSE_FILE` 로 `docker-compose.remote-proxy.yml` 을 더한다 — compose 가 `--env-file` 의 `COMPOSE_FILE` · `COMPOSE_PROFILES` 를 읽는다.
|
||||
빈 `COMPOSE_FILE=` 은 compose 가 실패하니 쓰지 않을 때는 주석으로 둔다).
|
||||
|
||||
+165
-100
@@ -3,34 +3,63 @@ name: flutter
|
||||
description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null Safety, feature-first 디렉터리 구조, 작은 위젯 분리, 반응형 UI, Riverpod 상태관리, Dio 인터셉터, flutter_secure_storage 토큰 저장. flutter/ 에서 작업하거나 위젯·프로바이더·모델·API 클라이언트를 만들 때 사용한다.
|
||||
---
|
||||
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다.
|
||||
> 이 스킬은 `.claude/skills/flutter/SKILL.md`에 위치한다. 앱 로그인의 근거는 계약 `docs/mail/api-contract.md` 1절 "앱 로그인 (앱 전용 토큰 경로)", 화면 기준은 `docs/design/app/README.md`, 개발 실행 · 배포 준비 메모는 `flutter/README.md` 다.
|
||||
> 사용법: `/flutter [요청 내용]` 또는 flutter/ 작업 시 자동 적용.
|
||||
> 공통 규칙: [[response-format]] · [[error-codes]] · [[api-design]] · [[security]] · [[coding-common]]
|
||||
> Flutter 는 docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립 관리한다 ([[env-structure]] 참조).
|
||||
|
||||
---
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
## 기본 골격
|
||||
|
||||
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 네트워크 · 세션 · 라우터 · 모듈 틀이 이미 있다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 앱 이름 "ARA SYSTEM", 식별자 `kr.it.arasystem.app`(Android applicationId · iOS bundle id — 직원 휴대폰에 설치한 뒤에는 못 바꾼다: 식별자가 다르면 다른 앱이라 그 위에 설치되지 않는다), 패키지 `arasystem_app`.
|
||||
|
||||
| 제공 항목 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
|
||||
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
|
||||
| 앱 뿌리 | `lib/main.dart` · `lib/app.dart` | main: dotenv → 글꼴 라이선스 → 토큰 보관 준비(첫 실행 지우기) → 기기 정보(판 · User-Agent) → `ProviderScope(overrides)`. app: `MaterialApp.router` · 한국어만(`locale: ko` · `GlobalMaterialLocalizations.delegates`) · 전역 `scaffoldMessengerKey` |
|
||||
| **모듈 목록** | `lib/core/modules/app_modules.dart` | **짝: 웹 `frontend/src/shared/constants/app-modules.ts`** — 같은 id · 이름 · 차례(한쪽을 바꾸면 둘 다). 항목마다 id · 이름 · 아이콘 · 첫 화면 경로(`/<id>`) · 켜짐. 웹은 준비 중 모듈을 꺼진 채 보이지만 앱은 **켜진 모듈만** 탭이 된다(첫 판: 메일 · 설정) |
|
||||
| 모듈 라우트 묶음 | `lib/features/<모듈>/<모듈>_routes.dart` → `lib/core/router/module_registry.dart` | 모듈마다 `ModuleRoutes`(`core/modules/module_routes.dart` — 탭 안 라우트 · 알림 id → 경로 · 설정 절) 하나. registry 의 exhaustive switch 가 id → 묶음. 켰는데 묶음이 없으면 라우터를 만들 때 `StateError` |
|
||||
| 라우터 · 탭 막대 | `lib/core/router/` — `app_router.dart` · `app_shell.dart` · `session_redirect.dart` · `app_routes.dart` | go_router. 로그인 전 · 제한 단계(`/start` · `/login/...`)는 셸 밖, 로그인 뒤는 `StatefulShellRoute.indexedStack`(켜진 모듈마다 탭 하나 — 탭마다 이동 기록을 지킨다). 아래 탭 막대(`NavigationBar`)가 웹의 전역 앱 레일이다. 읽기 · 쓰기 같은 전체 화면은 `parentNavigatorKey: rootNavigatorKey`. `redirect` 하나(`resolveSessionRedirect` — 순수 함수)가 세션 상태로 화면을 고르고, 모르는 · 꺼진 모듈의 주소는 메일 첫 화면(`onException` 도) |
|
||||
| **세션** | `lib/features/auth/presentation/providers/session_provider.dart` | `AsyncNotifier` 하나(`sessionProvider` — 저절로 다시 하지 않는다: Riverpod 3 의 `retry: null` 은 기본 다시 하기라 늘 null 을 돌려주는 `noAutomaticRetry` 를 준다). 상태 `SignedOut` · `CodeRequired` · `SignedIn(info.restriction)` + 되살리는 중(`AsyncLoading`) · 되살리기 장애(`AsyncError`). 로그인 때 넣은 비밀번호는 상태가 아니라 `LoginSecret`(메모리 · 10분 — 코드 단계 · 제한 단계가 쓴다). 인터셉터와는 `SessionEventBus`(`core/api/session_events.dart`)로만 잇는다 — 세션 끝 · `AUTH_010` 다시 맞추기(`SessionRecheckNeeded` 에 답 `RestrictionCheck` — renewed · restricted · stale · unknown, 웹과 같다) · 갱신 응답의 제한. **시도 세대**: 로그아웃 · [다른 계정으로 로그인] · 세션 끝 때 올라 그 전에 시작한 되살리기 · 로그인 · 코드 · 다시 맞추기의 늦은 결과를 버린다(버린 결과에 토큰이 있으면 조용히 앱 logout). 로그아웃은 토큰 · 상태를 먼저 지우고, 그 뒤 서버 logout 과 이 기기의 알림 멈추기(알림창 지우기 · FCM 등록 풀기)를 함께 보낸다(서로 기다리지 않는다). 제한이 풀리면 쥔 비밀번호를 지운다 |
|
||||
| 앱 로그인 저장소 | `lib/features/auth/data/` | 계약 1절 "앱 로그인"(`/auth/app/login` · `refresh` · `logout`(리프레시 토큰 + Bearer) + Bearer `me`, 인터셉터 줄 밖의 세션 확인 `readSessionDirect`). 실패는 늘 `DioException`(`error` 에 `ApiException` — 응답 모양이 틀리면 `SYS_001`, `apiFailure()`). 모델은 손으로 쓴다(`AppLoginResult` · `AuthSessionInfo` · `SessionRestriction` — 모르는 제한은 `unknown`). 로그인 화면이 칸에 보이는 실패(`kLoginInlineCodes`)는 알리지 않는다 |
|
||||
| **로그인 화면 (네 단계 — `App1Login`)** | `lib/features/auth/presentation/pages/`(login · login_code · password_change · two_factor_setup · restricted_login) · `widgets/`(`login_frame` · `login_fields` · `login_parts`) · 글 `core/constants/login_texts.dart`(제목은 `screen_titles.dart`) · `data/`(`two_factor_repository` · `support_contact_repository` · `authenticator_launcher`) | 노티파이어의 제한 단계 API(`attempt` · `passedCodeStep` · `heldLoginListenable` · `changePassword` · `enableTwoFactor` · `finishRestrictedStep` → `RestrictedStepResult { done, needsPassword, stale }`). 로그인 비밀번호는 두 제한 단계에서만 10분 쥔다(모르는 제한 · 제한이 풀리면 지움). 칸 · 상자에 보이는 코드만 알림을 끈다(`kLoginInlineCodes` · `kLoginCodeInlineCodes` · `kPasswordChangeInlineCodes` · `kTwoFactorSetupInlineCodes` — 앱 디자인 README 의 표), `AUTH_002` 는 스낵바 없이 로그인 화면 상자만. 2단계 키 · otpauth 주소는 그 화면 State 에만. **url_launcher**: `canLaunchUrl` 은 쓰지 않는다(debug 판이 주소째 로그를 남긴다) — `launchUrl(externalNonBrowserApplication)` 만, 실패 예외에 주소가 들어 있어 삼킨다, `<queries>` 는 필요 없다. 한글 줄바꿈은 `shared/widgets/keep_all_text.dart`(시험은 `findCopy`). 보안 설정 요청 한도 `kSecuritySettingTimeout` 30초 |
|
||||
| **메일 목록 (`App1MailList`)** · 앱 틀 | `lib/features/mail/` — data(models · `mail_repository.dart`) · domain(`mailbox_tree` · `mail_list_merge` · `mail_bulk` — 순수 함수) · presentation(`pages/mail_list_page.dart` · providers · widgets), 틀 `core/router/app_shell.dart` · `shell_bar.dart`, `core/utils/`(`app_foreground` · `accessible_navigation` · `korean_format`), `shared/widgets/confirm_dialog.dart`, 시험 도구 `test/support/mail_fakes.dart` · `mail_harness.dart` | **틀은 Scaffold 가 아니다**(Column + 탭 막대 — 탭 안 화면마다 자기 Scaffold 를 둔다, 없으면 스낵바 · Material 조상이 없다). 탭 막대는 `MediaQuery.removePadding(removeTop)` 으로 감싸고, 화면이 숨기려면 `ShellBarHidden`(모듈마다 센다 — 보이는지는 TickerMode). Scaffold 는 넓은 단추 자리에 안전 영역을 넘기지 않으니 Scaffold 밖에서 읽어 넘긴다. 목록 상태는 메일함마다(`mailListProvider(String?)` autoDispose), 세션이 바뀌면 `mailAccountProvider` 로 처음부터. 알림은 두 갈래 — 단추 있는 `mailToastProvider`(한 번에 하나 · 화면 낭독기 켠 동안 닫지 않음) · 단추 없는 `mailNoticeSinkProvider`(전역 스낵바). 생명주기 안에서는 프로바이더를 바꾸지 않는다(그린 뒤 · 마이크로태스크), 다른 노티파이어 인스턴스를 든 노티파이어는 세션이 바뀐 뒤 다시 읽는다. 가짜 서버는 응답이 한 프레임 안에 오므로 "받는 중이 끝나는 순간"을 기다리는 위젯을 만들지 않는다 |
|
||||
| **메일 읽기 (`App1Reader`)** | `lib/features/mail/` — domain(`mail_html`(두 번째 정화 · 중첩 비용 · CSP 문서 · 중계 그림 바꿔 넣기) · `mail_images` · `mail_links` · `compose_request` · `mail_body_notices`) · `data/`(`models/message_detail.dart` · `mail_link_launcher.dart`) · presentation(`pages/message_reader_page.dart` · `providers/message_reader_controller.dart` · `attachment_downloads.dart` · widgets `mail_body_view` · `reader_*`), 시험 도구 `test/support/fake_webview.dart`(`WebViewPlatform.instance`) · `file_fakes.dart` | **WebView 에는 `buildMailDocument` 의 문서만 넣는다**(CSP 를 맨 앞 · JS 끔 · 처음 문서 말고는 탐색을 막음 · 안드로이드는 파일 · content 접근 · 위치 · 섞인 내용 끔, 아래 틀(iframe)의 탐색은 판정을 거치지 않으니 막는 것은 정화(내용째 버림)와 CSP 다). **보이는 것이 없는 본문이면 본문 칸(WebView)을 두지 않는다**(2026-11-12 — 첨부만 있는 메일에서 아래 절반이 빈 칸으로 남고 첨부가 위 절반에서 잘렸다): 판정은 정화 길이 함께 낸다(`SanitizedMailBody.hasVisibleContent` — 남긴 글 가운데 `core/utils/visible_text.dart` 의 `hasVisibleText`(공백 · 제어 문자 · Default_Ignorable(제로폭 · 방향 표시 · 한글 채움 등) · 점자 빈칸은 보이지 않음)인 글, 주소가 남은 그림(`_attributesOf` 가 src 를 맨 앞에 쓰는 모양에 기댄다 — 외부 그림은 받기 전에도), 글 없이 그려지는 `hr` · `li` · `details` · `q`. 꾸밈(style · bgcolor · border)만은 치지 않는다. 고르는 본문(`displayedHtmlBody`)은 그대로) → 상태 `bodyEmpty` → 머리의 최대 높이가 화면 전체(`kReaderHeadMaxFraction` 대신 — 그림 받는 진행 막대 자리만 뺀다 · 0 아래로 내려가지 않게), 아래 막대가 없는 메일(예약)은 머리 스크롤에 시스템 아래 여백을 더한다. 문서를 만들기 전(`document == null`)은 지금처럼 본문 자리의 진행 줄. 받은 HTML 은 서버가 정화했어도 `sanitizeMailHtml` 을 한 번 더 지난다 — package:html 은 `parse().body`(`parseFragment` 는 맨 위 형제가 많으면 제곱), 그 전에 `htmlNestingCost`(선형 · 파서보다 깊게 센다)로 한도를 보고, 바로 할지 isolate 로 보낼지는 `mailBodyBuildPlaceOf` 하나로 고른다. 중계 그림은 Dio(Bearer)로 받아 `data:` 로 넣고 토큰은 WebView 에 가지 않는다 — 문서에 싣는 양은 **나온 횟수까지** 센다(`resolveProxiedImages`). 응답은 그 요청이 바꾼 칸만 지금 요약에 덮는다(`_applyResponse`). 메일 id 마다 autoDispose 프로바이더(라우트 `ValueKey(id)`) · 받기 세대 · 본문 세대 · 세션 세대로 늦은 응답을 버린다. 목록에는 `MailChange(updated, removedIds)` 로 알린다. 쓰기 화면을 여는 값은 웹과 같은 쿼리(`replyTo` · `all` · `forward` · `draft` — `composeIntentOf`), mailto 로 채울 값은 go_router `extra` 의 `ComposePrefill` |
|
||||
| 받은 파일 · 고른 파일 · 형식 | `lib/core/files/` — `downloaded_files.dart`(`DownloadedFileStore` · `CacheDirFileStore` · `FileOpener`) · `picked_files.dart`(`PickedFileStore` · `PlatformPickedFileStore` — 채널의 `pick`) · `file_names.dart`(`safeFileName` · `displayFileName`) · `file_types.dart`(`fileOpenTypeOf` · `isBlockedFileOpen`), 안드로이드 `MainActivity.kt`(채널 `kr.it.arasystem.app/files`) · `res/xml/downloaded_file_paths.xml` · 매니페스트의 FileProvider `${applicationId}.downloads` | 모듈 공통(메일 첨부 · 나중의 파일 모듈). 자리는 `cacheDir/downloads/<무작위 16자>/<정리한 이름>` 뿐 — **짝 셋**: Dart `kDownloadedFilesFolder` · `downloaded_file_paths.xml` 의 cache-path · `MainActivity.kt` 의 `DOWNLOADS_DIR`(채널 이름도 짝). Kotlin 은 canonical 경로로 그 폴더 안인지 다시 보고 읽기 권한을 한 번만 준다. 파일 여는 플러그인(open_filex 등)은 쓰지 않는다 — 저장소 권한 · 기기 전체 경로를 더한다. 열 형식은 확장자 먼저(`package:mime`), 보낸 쪽 형식은 확장자를 모를 때만 — 형식 글 모양 검사는 짝: Kotlin `MIME_PATTERN` · `GENERIC_TYPE`. 막는 형식(웹 문서 · svg · 설치 파일 · 인증서)은 받지도 않는다. **고른 파일**(메일 쓰기 2차): 고르는 창(`ACTION_GET_CONTENT` — 권한 없음 · `*/*` · 여러 개)으로 고른 파일을 `cacheDir/uploads/<무작위 16자>/<정리한 이름>` 에 복사본으로 둔다 — **짝 둘**: Dart `kPickedFilesFolder` · `MainActivity.kt` 의 `UPLOADS_DIR`(다른 앱에 건네지 않아 FileProvider xml 에는 없다). 채널 인자 · 답의 칸 · 값도 짝이다 — Dart `PlatformPickedFileStore.pick` · `PickResult.parse` · `PickedFile.fromChannel` ↔ Kotlin `ARG_*` · `KEY_*` · `OUTCOME_*` · `STATUS_*`. Kotlin 은 `content://` 만 읽고, Dart 사전 검사와 같은 차례(개수 → 형식 → 파일당 → 합계 — `copyPicked` ↔ `precheckPickedFile`)로 넣을 파일만 복사한다 — 미리 거르기일 뿐 판정은 Dart 가 다시 한다. 확장자를 떼는 규칙도 짝(Kotlin `displayFileName` · `extensionOf` · `isDartWhitespace` ↔ Dart `displayFileName` · `extensionOf` · `trim()`). 복사본 이름은 Kotlin `safeFileName`(Dart 와 같은 규칙), 보이고 보내는 이름은 `displayFileName` 이다. 복사 스레드는 Throwable 까지 잡아 한 번만 답한다. 고르기와 모두 지우기는 차례로 돌고, 지우기는 기호 링크를 푼 경로로 복사본 폴더 바로 아래 한 칸만 지운다. `pick` 은 그 차례 안에서 `kPickTimeout`(10분) 시한을 둔다 — 넘으면 `timedOut`(Dart 에만 있는 결과)으로 끝내고 차례를 넘긴다(늦게 온 복사본은 다음 정리에). 파일 고르는 플러그인은 쓰지 않는다(권한 · 기기 전체 경로) |
|
||||
| **메일 쓰기 (`App1Compose` — 1차 · 2차)** | `lib/core/people/`(사람 고르기 — 주소 칩 · 추천 맞추기 · 그룹, 모듈 공통. 표본 표 `test/core/people/recipient_match_samples.dart` 는 웹 · 백엔드와 **세 쪽 짝**) · `features/mail/domain/`(`compose_body` 직렬화 · `compose_fit`(`buildInitialQuote`) · `mail_quote` · `compose_draft_body`(`buildDraftBody` · `draftBodyPlaceOf`) · `compose_reply` · `compose_send` · `compose_attachments`(2차 — 형식 표 · 한도 · 사전 검사) · `compose_signature` · `html_nodes`) · `data/`(`compose_models` · `compose_payload` · `repositories/compose_repository.dart`) · presentation(`pages/message_compose_page.dart` · `providers/compose_controller.dart`(화면마다 하나 — `ComposeArgs.instance`, autoDispose) · `draft_autosave.dart` · `attachment_uploads.dart`(`AttachmentUploadQueue` — 둘씩 · 취소 · 다시 올리기) · `signature_image_provider.dart` · widgets `compose_*`) · `core/utils/utf16_length.dart` · 시험 도구 `test/support/compose_fakes.dart` | 본문 모양은 계약 2절 "앱이 만드는 모양". 웹 짝(`mail-address.ts` · `recipient-match.ts` · `MAIL_HTML_STYLES` · `docToText` · `fit-draft` · `DRAFT_*` · `shouldRenewSendKey`)은 각 파일 머리말에 적었다 — 한쪽을 고치면 둘 다. **package:html 의 `text` · `querySelectorAll` · `innerHtml` · `outerHtml` 은 깊이만큼 되풀이한다** — 쓰기 화면은 `html_nodes.dart`(되풀이 없는 글 모으기 · 찾기 · 직렬화)를 쓰고, 저장본 · 원문은 읽기 화면과 같은 한도 · 문턱(`mailBodyBuildPlaceOf`)으로 그 자리 · isolate · 읽지 않음을 고른다. **화면을 떠나도 끝까지 가야 하는 일(보내기)은 기다림을 시작하기 전에 `ref.keepAlive()` 를 잡고 try/finally 로 놓는다**(2026-11-07 리뷰 — 저장을 기다리는 사이 내려가 메일이 나가지 않았다). 임시 저장은 Timer 만 쓴다(시험은 `tester.pump` 로 시간을 넘긴다). 보내기 결과는 `mailToastProvider` 의 마감 알림(목록 · 읽기 화면이 그린다). 서버와 같은 한도를 거는 칸은 `Utf16LengthLimitFormatter`(UTF-16 단위). **첨부(2차)**: 형식 표 `kMailAttachmentTypes` 는 **짝: 백엔드 `attachment.policy.ts` 의 `ATTACHMENT_TYPES`** — 시험이 그 파일을 읽어 표와 `MAX_ATTACHMENTS_PER_MESSAGE` 를 맞춰 본다 (앱 CI 는 flutter/ 가 바뀐 커밋에서만 돈다 — 백엔드 표만 고친 커밋에서는 이 시험이 돌지 않으니 함께 고친다). 사전 검사는 웹 `precheckAttachment` 의 차례, 보내기 막기는 웹 `sendBlockOf` 의 차례(가져오는 중은 올리는 중 다음). 저장소 `uploadAttachment`(multipart `file` · 확장자의 정식 형식 · 보내기 5분 · 받기 60초 · 알림 끔 — 401 뒤에는 FormData 를 복제해 복사본을 처음부터 다시 읽는다) · `getAttachmentLimits`(`GET /api/mail-quota`). 다 올리면 서버의 이름 · 크기로 싣는다(이름 255자 — 웹과 같다, 2026-11-07 리뷰). `MAIL_004` 뒤 다시 올릴 수 없는 고른 파일은 `problem: missing` 으로 막는다(웹 `markMissing`). 시험 도구 `FakePickedFiles`(`test/support/file_fakes.dart`) · `FakeComposeBackend` 의 `uploads` · `uploadGates` · `quota` · `quotaGate` · `FakeServer.readRequestBodies`. **칸 줄**은 `ComposeFieldRow`(`widgets/compose_recipient_field.dart`) 하나 — 칸 이름 72 · 글자 15 · 간격 6 · 여백 4 4 4 16, 칸 이름 · 오른쪽 단추는 첫 줄(`ComposeFieldRow.lineHeight` 48)의 가운데(받는 사람은 `firstLineHeight` 로 칩 줄 40 을 넘겨 칩이 여러 줄이어도 첫 칩 줄에 맞춘다), 아래 선은 `foregroundDecoration` 이라 초점으로 굵어져도 칸이 움직이지 않는다. 입력칸은 `borderlessInputDecoration`(2026-11-12 — 아래 함정). 본문 칸 최소 높이는 `composeBodyMinHeightOf`(원본 메일이 있으면 150 · 없으면 240 — 서명과 상관없다, 보드 App1Compose) |
|
||||
| **메일 검색 (`App1Search` — 1차 · `App1SearchConditions` — 2차)** | domain `search_text`(**짝: 웹 `search-match.ts` · `mail-search.ts` · 백엔드 `search-text.ts`** — 표본 표는 시험이 `backend/test/search-text-samples.ts` 의 FOLD · GRAM 을 직접 읽는다 · `test/support/ts_table.dart`) · `search_highlight`(짝: 웹 `search-highlight.ts`) · `search_conditions`(2차 — **짝: 웹 `mail-search.ts` · `mail-vacation.ts`** — 조건 다듬기 · 합친 낱말 수 · 기간 · after/before(기기 시간대 0시 → UTC, 끝 날짜는 다음 날 0시) · 강조 쿼리 · "나") · `data/models/mail_search.dart` · `mail_repository`(`searchMessages` — 응답의 `Date` 로 서버 시각 · `searchIndexStatus` · `getMessage(highlight)`) · presentation(`pages/mail_search_page` · `providers/mail_search_controller`(화면마다 — `MailSearchArgs.instance`, autoDispose) · `mail_search_state`(`waitsForMore`) · `mail_search_view` · widgets `mail_search_bar`(+ 공용 `LineBreaksToSpacesFormatter`) · `mail_search_states` · `highlighted_text` · `mail_search_conditions_sheet`(2차)) · `core/utils/js_case.dart` | 탭 안 `/mail/search`(시작한 메일함은 `extra` 의 `MailSearchStart`, 검색어는 주소에 싣지 않는다). 이어 찾기는 한 차례 `kMailSearchFillMaxRequests`(5) · 20통, 모자라면 [더 찾기]로 멈춘다. 검색 세대 + `CancelToken`(새 검색 · 화면 닫기에 끊는다), 준비 상태는 따로 세대. 결과는 `MailChange` 와 붙잡힌 줄 다시 받기(보낼 시각 + 2초)만 반영한다. **강조 위치는 그 응답의 글에만**. 읽기 키는 `MessageReaderArgs(id, highlight)`, 결과에서 연 메일은 `MailSearchOpen`. 조건(2차)은 화면 상태 `MailSearchState.conditions` 로 이어 찾기 · 새로고침 · 다시 시도 · 찾을 곳 바꾸기에 이어지고, "나"는 `mailSelfProvider`. 시트 가림막은 테마의 바닥 시트 가림막(`AppColors.backdrop` — 옮기기 시트도), 날짜 창은 `showDatePicker(barrierColor:)` · 머리 22(`datePickerTheme`). 노랑 `AppColors.searchMark` 는 **짝: 웹 `--color-search-mark` · `mail_html.dart` 의 mark**. 시험 도구는 `FakeMailBackend` 의 `defaultSearch`(받은 시각 내림차순 · `searchCursorAfter` 는 그 줄 다음부터) · `move` · `searchResponder` · `searchGate` · `searchHighlights` · `highlightDetails` · `searchIndex` |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기 · 로그인 없는 앱 설정용(언래핑만 — `publicDioProvider`. 인증 인터셉터는 요청을 한 줄로 세워 앞 요청의 미리 갱신을 기다리게 하므로, 앱을 띄우자마자 묻는 앱 설정은 줄 밖으로 보낸다). baseUrl=dotenv `API_URL`(`api_config.dart`), User-Agent `ARASYSTEM-App/<판> (Android <판>; <기종>)`(`user_agent.dart`). `LogInterceptor` 는 넣지 않는다(비밀번호 · 토큰). 시험은 `httpClientAdapterProvider` 로 가짜 서버를 끼운다 |
|
||||
| 언래핑 · 코드 정규화 | `lib/core/api/response_interceptor.dart` · `api_error_mapper.dart` | `{success,data}→data`(스트림 · 바이트는 그대로 — 그 **실패** 본문은 64KB 까지 읽어 `error.code` 를 꺼낸다(`decodeRawErrorBody`). 스트림을 받는 도중의 실패는 인터셉터를 거치지 않아 부르는 쪽이 `SYS_001` 로 알린다(`DownloadInterrupted`). 비동기 처리기는 무슨 일이 있어도 `handler.next` 로 끝낸다 — 던지면 요청이 매달린다), 200 + `success:false` 도 실패, 실패는 모두 `DioException.error` 에 `ApiException`. 상태 → 코드 폴백(웹 `resolveCodeByStatus` 와 같다) |
|
||||
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}`. 화면은 `apiErrorCodeOf(e)` 또는 `e.error as ApiException` 의 **`code`** 로 가른다 |
|
||||
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
|
||||
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
|
||||
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
|
||||
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
|
||||
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
|
||||
| **인증 인터셉터** | `lib/core/api/auth_interceptor.dart` · `token_refresher.dart` | `QueuedInterceptor`. Bearer 붙이기 · 액세스 토큰이 없거나 30초 안에 끝나면 미리 갱신 · 401 이면 갱신 한 번 → 원래 요청 한 번 다시(로그인 · 갱신 · 로그아웃 제외 — `ApiPaths.noRefresh`). 갱신은 `TokenRefresher` 가 **한 번에 하나**(동시 401 은 토큰 세대를 보고 묶는다). 갱신 401 · 유예 응답(`tokens: null`) → 토큰 지움 + `AUTH_002` + 세션 끝, 5xx · 연결 끊김은 세션을 두고 그 코드로 알림, 갱신 중 로그아웃이면 받은 토큰을 버리고 서버 세션도 끝낸다. 갱신의 일시 장애는 같은 토큰 세대에서 5초 동안 다시 쓴다(`kRefreshFailureReuse` — 연결이 끊긴 동안 줄 선 요청마다 갱신하지 않게). `AUTH_010` 은 세션 노티파이어에 다시 맞춰 달라고 하고(`requestRecheck` — 안전 한도 65초) renewed 면 한 번 다시 보낸다 · restricted · stale 은 알리지 않는다 · unknown 은 알린다(웹 `recheckRestriction` 과 같은 결). 같은 토큰으로 줄 선 `AUTH_010` 은 한 번만 묻는다(그사이 새 토큰이면 묻지 않고 다시 보내고, restricted 는 그 세대 동안 · unknown 은 5초 다시 쓴다). **다시 보낸 요청의 실패도 첫 요청과 같은 규칙**(401 · `AUTH_010` 마다 한 번씩 — `kAuthRetriedExtraKey` · `kRestrictionRetriedExtraKey`). **요청을 보낸 세션이 끝났으면**(세션 세대 `kSessionGenerationExtraKey` 가 다르다 — 로그아웃 · 세션 끝 · 그 뒤 다른 직원 로그인) 어떤 실패든 조용히 끝낸다 — 401 은 사용자가 로그아웃했으면 `AUTH_001` 그대로, 세션이 끝났으면 `AUTH_002`. 실패를 내보내기 전에 Bearer · 비밀 본문을 지운다(`withoutSecrets`). 처리기는 뜻밖의 예외에도 꼭 부른다(`SYS_001`). 이 인터셉터의 오류 처리 안에서 보내는 요청(갱신 · 세션 확인 · 다시 보내기)은 모두 안쪽 Dio — 같은 줄로 보내면 서로 기다린다 |
|
||||
| 오류 알림 | `lib/core/api/error_notifier.dart` | 전역 `ScaffoldMessenger` 키 하나로 스낵바(웹 `showErrorUI` 자리). 같은 코드는 3초 안에 다시 띄우지 않는다. 요청마다 끄기: `Options(extra: {kSilentExtraKey: true})` 또는 코드 목록(`ApiOptions.silentFor`) — `request_flags.dart`. **비밀이 든 요청**(비밀번호 · 코드 · 리프레시 토큰이 본문에)은 `ApiOptions.secret(...)` 으로 보낸다 — 실패 예외의 본문을 비워 던져 화면이 `AsyncValue` 로 상태에 두어도 된다 |
|
||||
| 토큰 보관 | `lib/core/storage/` — `token_store.dart` · `secure_store.dart` · `first_run_marker.dart` | 리프레시 토큰만 `flutter_secure_storage`(키 상수 `kRefreshTokenKey`), 액세스 토큰은 메모리. 새 토큰은 받자마자 저장(로그인 저장이 실패하면 그 세션을 끝내고 `SYS_001`, 갱신 저장이 실패하면 이번 실행은 메모리의 새 토큰으로 이어 가고 다음 읽기 · 저장 때 다시 쓴다). 보안 저장소 일은 부른 차례대로 하나씩(로그인 저장과 로그아웃 지우기가 엇갈리지 않게). 토큰 세대 `generation` · 세션 세대 `sessionGeneration`(지울 때만 오른다), 로그아웃은 `signOut()`(메모리를 곧바로 비우고 서버 logout 에 쓸 토큰을 돌려준다). 만료는 기기 시계로 보지 않는다 — 응답의 `Date`(서버 시각, `core/api/server_time.dart`)로 남은 시간을 재고 받은 때부터 센다. 첫 실행 표시(`shared_preferences` — 참 · 거짓 하나)가 없으면 보안 저장소를 모두 지운다(iOS Keychain 은 앱을 지워도 남는다). Android 는 백업을 껐다(`AndroidManifest` · `res/xml/data_extraction_rules.xml`) |
|
||||
| 테마 | `lib/core/theme/` | 색은 웹 `tokens.css` 를 `ColorScheme` 에 손으로 대응(`app_colors.dart` · `app_theme.dart` — 시드 생성 쓰지 않음, Material 에 없는 색은 `AppExtraColors`). 간격 · 모서리 `AppSpacing` · `AppRadius`, 누르는 곳 최소 48dp(`AppSize.touchMin`). 글자는 `app_typography.dart` 한 곳 — 화면 글 15 · 입력 칸 16 · 위 막대 제목 18 · 메일 본문 15/1.75 는 확정(`docs/design/app/README.md`), 그 밖의 역할은 임시 값. 테두리 없는 입력칸(칸 줄이 아래 선 · 초점을 그리는 쓰기 화면 · 검색 막대)은 `borderlessInputDecoration` — 테마의 입력칸 테두리를 물려받지 않는다 |
|
||||
| 글꼴 | `assets/fonts/` · `assets/licenses/pretendard-ofl.txt` · `core/theme/app_fonts.dart` | Pretendard 1.3.9 정적 OTF 넷(400 · 500 · 600 · 700 — 웹 npm `pretendard` 와 같은 파일). OFL 1.1 글을 `LicenseRegistry` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| **새 메일 알림 (`App1Push`)** | `lib/core/push/` — 포트 `push_messaging.dart`(FCM 은 `firebase_push_messaging.dart` — 띄우지 못하면 `UnavailablePushMessaging`) · `notification_system.dart`(채널 · 알림 지우기 · 휴대폰 설정 열기 — 안드로이드 `NewMailNotifications.kt`, 채널 `kr.it.arasystem.app/notifications`) · `push_preferences.dart`(이 휴대폰의 값 — `shared_preferences`, 참 · 거짓과 FID 하나뿐) · `push_state.dart`(상태 · 설정 줄 모습 `pushRowStatusOf` · 권하기 줄 `showsPushOffer`) · `push_controller.dart`(앱에 하나 — `PushRegistrar` 를 구현) · `push_registration.dart`(서버 저장소 · `pushRegistrarProvider`) · `push_events.dart`(앞에서 받음 · 누름 — 라우터 `core/router/push_navigation.dart` 가 누른 알림을 늘 메일 받은편지함으로) · `push_target.dart` · 글 `push_texts.dart`. 화면 `features/mail/presentation/widgets/push_offer_line.dart` · `features/settings/presentation/widgets/settings_notification_row.dart` | **보낼 조건** = 받을 수 있는 휴대폰 ∧ 서버가 보낼 수 있음(app-config `pushAvailable` — 모르면 보낼 수 있는 것으로) ∧ 메일을 쓸 수 있는 세션(세션 노티파이어의 `register` · 제한 단계는 `pause`) ∧ 이 휴대폰에서 켬 ∧ 권한 ∧ 새 메일 채널. 맞추기 `_reconcile` 하나가 PUT · DELETE 를 고른다. **PUT** 은 세션이 열릴 때 한 번 · FID 가 바뀔 때 · 켤 때만(앞으로 올 때마다가 아니다), **DELETE** 는 끌 때와 권한 · 채널이 꺼진 것을 알았을 때만 — 로그아웃 · 세션 끝은 FCM `unregister()` 만(서버 줄은 서버가 세션과 함께 지운다, 풀지 못하면 `pendingUnregister` 로 다음 실행 · 앞으로 옴에 다시). **봤음** `DELETE …/unseen` 은 앞으로 올 때 · 앞에서 받은 새 메일 알림 때, 30초 간격 + 끝 한 번 — 앱이 앞에 있을 때만 보내고, 기다리는 동안 뒤로 가면 그 자리에서 보낸다(끝까지 기다리면 그사이 온 메일까지 본 것으로 센다). 앞에서 받은 알림 뒤의 봤음은 받은 때 + 1.5초(`kPushSeenAfterMessageDelay`)보다 일찍 보내지 않는다 — 서버는 seen_at 의 초부터 같은 초를 세고 받은 시각은 초 단위라, 곧바로 보내면 열지 않은 그 메일이 다음 알림에 다시 세어진다(2026-11-08 실기기). 켜기 · 끄기는 서버가 답한 뒤에 바꾼다(실패는 그 코드의 사전 문구 — 등록 소식이 없으면 `SYS_001`). **켜기가 실패하면 이 휴대폰의 값을 끔으로 둔다**(처음 값 켬에서 허락하고 켰어도 — 스위치는 꺼진 채, 저절로 다시 하지 않는다). 서버 일은 멈춘(`pause`) 바로 그 차례에도 보내지 않는다(`_canReachServer` 가 컨트롤러의 세션 값도 본다). FCM `register` · `unregister` 는 우리가 한 줄로 세운다(플러그인이 스레드 풀에서 돈다), `onRegistered` 는 방송이라 `register` 전에 듣는다. **풀기는 기다리기 전에 등록 세대를 올리고 등록을 내려 둔다** — 풀기가 실패하거나 오래 걸려도 같은 실행의 다음 로그인이 그 뒤에 다시 등록하고(같은 FID 의 PUT 이 서버의 앞 직원 줄을 지운다), 풀기 앞의 등록은 끝나도 마친 것으로 치지 않으며 "풀지 못한 등록" 표시도 지우지 않는다. 풀기가 줄에 있는 동안 온 등록 소식은 쓰지 않는다. 앱이 앞에 있으면 FCM 이 알림을 그리지 않는다 — 앞에서 받은 새 메일 알림은 메일 목록이 바로 새 메일을 확인한다(`mail_state_watcher`). 알림 데이터는 **모듈 이름과 종류**(`{ module: 'mail', kind: 'new-mail' }` · `session-ended`). FID · 주소 · 토큰을 로그 · 디버그 출력에 남기지 않는다. 시험 도구 `test/support/push_fakes.dart`(가짜 FCM · 알림창 · 값 · 서버) · `push_screen_harness.dart`(앱 전체 + 진짜 컨트롤러 — `ProviderHarness.recordPush = false`). 다른 시험대는 기기 등록을 기록하는 가짜(`RecordingPushRegistrar`)이고 컨트롤러는 FCM 이 없어 조용하다 |
|
||||
| **앱 설정 · 업데이트 화면** | `lib/core/app_config/`(`app_config.dart` — 판 비교 `AppVersion` · 모양, `app_config_repository.dart`, `app_config_controller.dart` — `appUpdateRequiredProvider` · `pushAvailableProvider`) · `core/router/session_redirect.dart`(`updateRequired` 가 세션보다 먼저) · `features/app_update/`(모듈이 아니다 — 화면 · 글) · `core/app_download/`([새 버전 받기] — 받기 화면 주소 `app_download_link.dart` · 런처 `AppDownloadLauncher` · 글, 업데이트 화면과 설정 › 앱 정보가 함께 쓴다) | `GET /api/app-config` 는 띄울 때 한 번 · 앞으로 올 때 마지막으로 받은 지 1시간이 지났으면(실패는 1분 간격으로 다시) — 알리지 않고 막지 않는다. 404 `RES_001` 은 옛 서버(최소 판 없음 · 알림 쓸 수 없음). 판은 숫자 1~3칸 · 칸마다 4자리까지(서버의 판 칸 `APP_VERSION_PATTERN` 과 같다 — `-` · `+` 뒤는 버린다), 이 앱의 판을 모르면 막지 않는다. 업데이트 화면으로 옮길 때 쓰기 화면은 마저 저장한다(`ComposeController.detach(saveRemaining: true)` — 화면이 밖에서 닫힌 때도). 앱은 스토어에 올리지 않고 사내에 직접 나눠 준다(2026-11-08 — 빌드 · 서명 · 올리기는 `flutter/README.md` "사내 배포"). [새 버전 받기]는 포털의 휴대폰 앱 받기 화면 `<API_URL 의 출처>/app`(`appDownloadPageUrlOf` — `/api` 를 뗀 출처 + 경로, 짝: 웹 `mobile-app.ts` 의 `MOBILE_APP_PATH`)을 바깥 브라우저로(`LaunchMode.externalApplication` — `canLaunchUrl` 은 쓰지 않는다) 연다. 열지 못하면 스킴 없는 주소를 담아 알린다(`openAppDownloadPage` — 두 화면이 같은 알림). 앱 설정 계약은 받는 곳 주소를 싣지 않는다 |
|
||||
| **설정 탭 (`App1Shell` · `App1Push` ④)** | `lib/features/settings/` — `settings_routes.dart`(`/settings` · `/settings/licenses` · `/settings/release-notes`(+ `/:noteId`) · 모듈이 내놓은 절) · `presentation/pages/settings_page.dart` · `widgets/settings_notification_row.dart` · `settings_texts.dart` | 한 화면(내 정보 · 알림 · 모듈 절 · 앱 정보(앱 버전 · 업데이트 내역(목록 → 글, 아래 행) · [새 버전 받기] — 끝 아이콘이 바깥으로 여는 아이콘, 업데이트 화면과 같은 런처 · 오픈소스 라이선스) · 문의 · [로그아웃]). 문의는 로그인 화면과 같은 저장소(`supportContactProvider`), [로그아웃]은 확인 창(`shared/widgets/confirm_dialog.dart` — 처음 초점 [취소]). 스위치 색은 테마의 `switchTheme`(보드 — 켬 청록 · 끔 테두리 · 누를 수 없음 회색) |
|
||||
| **업데이트 내역 (`ReleaseNotes` B — 2026-11-10)** | `lib/features/settings/` — data(`models/release_note.dart` · `repositories/release_notes_repository.dart`) · presentation(`pages/release_notes_page.dart` · `release_note_page.dart` · providers `release_notes_providers.dart` · `release_notes_list_controller.dart` · `release_note_controller.dart` · widgets `release_notes_parts.dart` · 글 `release_notes_texts.dart`), 달력 날짜 `core/utils/calendar_date.dart`(`CalendarDate`) + `korean_format.dart` 의 `koreanCalendarDate`, 시험 도구 `test/support/release_notes_fakes.dart`(`FakeReleaseNotesBackend` · 보드의 네 글 `kBoardReleaseNotes`) | 계약 7절(`GET /api/release-notes?cursor=&limit=` · `GET /api/release-notes/{id}` — 올리는 API 없음, 글은 서버의 글 파일). 설정 › 앱 정보의 앱 버전 다음 줄 → 목록 `/settings/release-notes` → 글 `/settings/release-notes/:noteId`(둘 다 `parentNavigatorKey: rootNavigatorKey`, 글은 목록 라우트의 자식이라 주소로 바로 열어도 아래에 목록). 목록은 `releaseNotesListProvider`(autoDispose — 화면을 열 때마다 새로 받는다 · 첫 쪽 · [다시 시도]는 `silent`, [더 보기]는 알림 + 받은 줄 그대로 · 겹친 id 는 건너뜀), 글은 `releaseNoteProvider(id)`(autoDispose family · `silent` — 없는 글 404 `RES_001` 은 [목록으로], 그 밖은 [다시 시도]). 둘 다 `releaseNotesAccountProvider`(세션의 주소 — 메일의 `mailAccountProvider` 와 같은 규칙, 모듈끼리 import 하지 않아 따로)를 듣고 세션이 바뀌면 처음부터 · 세대(`_seq`)와 시작할 때의 Ref 로 늦은 응답을 버린다. 응답 모양이 틀리면 `ReleaseNotesMalformedResponse`(`SYS_001` — 인터셉터를 다 지난 뒤라 [더 보기]만 컨트롤러가 `errorNotifierProvider` 로 알린다, 메일 `DownloadInterrupted` 와 같은 결). **날짜는 `CalendarDate` 로만**(`YYYY-MM-DD` 를 글로 나눠 쥔다 — `DateTime` 으로 바꾸지 않는다, 없는 날은 모양 틀림). 다른 글 줄은 `context.pushReplacement`(쌓지 않는다 — 뒤로는 늘 목록, go_router 17 은 void 를 돌려준다). 글은 서버 글 그대로(마크다운 해석 · 링크 없음) · 한글 줄바꿈 `KeepAllText`(시험은 `findCopy`). [더 보기]는 받는 동안 누름만 무시한다(초점 지키기 — 메일 검색 [더 찾기]와 같다) — 도는 표시가 있어 시험은 `pumpAndSettle` 대신 `pump(100ms)` |
|
||||
| 불러오기 상태 틀 | `lib/shared/widgets/load_states.dart` · `load_state_texts.dart` | 가운데 상태 `StateLayout`(아이콘 원 72 · 제목 · 글 · 단추) · 받지 못함 `LoadErrorView`(사전 문구 · 문의 코드 · [다시 시도] — `RES_001` + `onLeave` 면 `leaveLabel` 단추, 둘은 함께 준다) · 뼈대 `LoadingSkeleton`(화면 낭독기 한 줄 + 막대는 읽지 않음) · `SkeletonBar`(메일은 모서리 4, 업데이트 내역은 `rounded`) · `traceCodeOf`. 2026-11-10 메일 목록(`mail_list_states.dart` 의 `MailStateLayout` · `MailListError` · `_SkeletonBar`)에서 모양 · 동작 그대로 옮겼다 — 메일 목록 · 검색 · 읽기 · 쓰기와 업데이트 내역이 쓴다. 메일 줄 꼴의 뼈대(`MailListSkeleton`) · 빈 메일함 · 목록 끝 · 30일 줄은 메일에 남았다. `MailListTexts.retry` · `traceCode` 는 `LoadStateTexts` 를 가리킨다 |
|
||||
| 자리 화면 | `lib/shared/widgets/placeholder_page.dart` | 화면은 캔버스로 확인받은 뒤 만든다 — 그때까지 이름(과 갈 곳)만 보이는 자리 화면. 확정되면 라우트 묶음의 builder 를 바꾼다 |
|
||||
| 예제 feature(참조용) | `lib/features/user/` | 목록 페이지네이션(`{items, pageInfo}` — 단건 + 커서 목록) 예제로만 남겼다. 앱 API 에 `/users` 는 없다 |
|
||||
| 시험 도구 | `test/support/` — `fakes.dart` · `provider_harness.dart` | 가짜 서버(`FakeServer` — HttpClientAdapter) · 메모리 보안 저장소 · 손 시계(`ManualClock`) · 알림 기록(`RecordingNotifier`). 고정 대기 없이 Completer 로 차례를 맞춘다 ([[testing]]) |
|
||||
|
||||
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
|
||||
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
|
||||
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
|
||||
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
|
||||
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
|
||||
> - **새 모듈 붙이는 법**(웹 [[vue3]] "새 사내 모듈"에 해당): `core/modules/app_modules.dart` 의 항목을 켜고(`isEnabled`) → `features/<모듈>/<모듈>_routes.dart` 에 `ModuleRoutes`(첫 라우트 경로 = `/<모듈>`)를 만들고 → `core/router/module_registry.dart` 의 switch 에 그 묶음을 적는다. 탭 · 주소 · 알림 · 설정 절은 저절로 붙는다. 웹 목록도 함께 본다(짝)
|
||||
> - **모듈끼리 서로 import 하지 않는다.** 함께 쓰는 것(사람 고르기 · 파일 첨부 · 날짜 표기 등)은 `core/` · `shared/` 에 둔다. 세션(`features/auth`)은 모듈이 아니라 모두가 쓴다. 다른 모듈의 설정 화면에 절을 더할 때는 `ModuleRoutes.settingsSections` 로 내놓기만 한다
|
||||
> - **주소는 모듈 이름부터**(`/mail/...` · `/settings/...` · 나중 `/calendar/...`). 깊은 링크 · 푸시도 모듈 이름으로 고른다
|
||||
> - **상태관리 · 복제할 패턴**: `features/auth` 의 `data/models`(손으로 쓴 모델) → `data/repositories`(`dioProvider` 의 Dio) → `presentation/providers`(타입을 적은 Riverpod 프로바이더 · 노티파이어)를 복제한다. 목록은 `features/user` 의 커서 페이지 예제. import 는 `package:arasystem_app/...`
|
||||
> - **화면 글**: 디자인 README 「화면 문구 — 사용자에게 말하듯」. 모듈 화면의 글은 그 모듈 안(`features/<모듈>/presentation/<모듈>_texts.dart`), 로그인 단계는 `core/constants/screen_titles.dart`
|
||||
> - **모델 생성기**: `freezed` · `json_serializable` · `riverpod_generator` 는 아직 넣지 않았다 — build_runner 가 같은 폴더의 `error_lexicon.g.dart`(자동 생성 사전)를 자기 출력으로 보고 지울 수 있다. 넣을 때는 `build.yaml` 의 `generate_for` 를 모델 폴더로 좁히고 한 번 돌려 사전이 남는지 확인한다
|
||||
> - **권장 패키지(선택)**: `cached_network_image`·`fpdart`·`webview_flutter`(받은 메일) 도입 시 `pubspec.yaml` 추가. 새 패키지는 배포 2주가 지난 판만 ([[security]] "의존성")
|
||||
|
||||
### Flutter 판
|
||||
|
||||
**개발 PC 와 CI 를 같은 판으로 고정한다 — 지금 `3.41.9` stable (Dart 3.11.5).** 함께 고치는 곳: `.github/workflows/ci-flutter.yml` 의 `flutter-version` · `flutter/pubspec.yaml` 의 `environment.sdk` 와 `intl`(`flutter_localizations` 가 SDK 마다 정확한 판으로 고정한다 — 지금 `0.20.2`, 새 SDK 의 `packages/flutter_localizations/pubspec.yaml` 값으로) · `flutter/README.md` · [[docker]] 이미지 버전 표의 "Flutter SDK" 줄. CI 는 `flutter pub get --enforce-lockfile` 로 lock 그대로 받는다.
|
||||
개발 PC 의 Flutter 는 다른 프로젝트도 함께 써서 그 판에 맞췄다 — 3.47 로 올리는 것은 사용자가 정한다(올리면 개발 PC · CI 를 함께, 2주 규칙).
|
||||
|
||||
---
|
||||
|
||||
@@ -47,26 +76,36 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
```
|
||||
lib/
|
||||
├── main.dart
|
||||
├── app.dart ← MaterialApp / 라우터 설정
|
||||
├── core/
|
||||
│ ├── constants/ ← 상수 (색상, 크기, 텍스트 등)
|
||||
│ ├── theme/ ← 앱 테마 정의
|
||||
│ ├── api/ ← Dio 인스턴스(dio_client), 에러 사전(error_lexicon), 인터셉터
|
||||
│ ├── storage/ ← flutter_secure_storage 래퍼 (선택)
|
||||
│ └── utils/ ← 순수 유틸 함수
|
||||
├── app.dart ← MaterialApp.router · 한국어 · 테마 · 전역 알림 자리
|
||||
├── core/ ← 모듈 공통 (모듈은 core · shared 만 함께 쓴다)
|
||||
│ ├── api/ ← Dio(dio_client) · 인터셉터 셋 · 토큰 갱신 · 에러 사전 · User-Agent
|
||||
│ ├── constants/ ← 앱 이름 · 로그인 단계 화면 이름 · 앱 판
|
||||
│ ├── modules/ ← 모듈 목록(app_modules — 웹과 짝) · 라우트 묶음 꼴(module_routes)
|
||||
│ ├── app_config/ ← 앱 설정(최소 판 · 알림을 보낼 수 있는가)
|
||||
│ ├── app_download/ ← [새 버전 받기] — 포털의 휴대폰 앱 받기 화면 주소 · 런처 · 글 (업데이트 화면 · 설정이 함께 쓴다)
|
||||
│ ├── push/ ← 새 메일 알림(FCM · 채널 · 서버 등록 맞추기) · 알림 → 경로
|
||||
│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry)
|
||||
│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시
|
||||
│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴
|
||||
│ └── utils/ ← 순수 유틸 함수 (시계 등)
|
||||
├── features/
|
||||
│ └── user/
|
||||
│ ├── data/
|
||||
│ │ ├── models/ ← JSON 직렬화 모델
|
||||
│ │ └── repositories/ ← API 호출 구현체
|
||||
│ ├── domain/
|
||||
│ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ └── presentation/
|
||||
│ ├── pages/ ← 화면 단위 위젯
|
||||
│ ├── widgets/ ← 재사용 위젯
|
||||
│ └── providers/ ← 상태 관리
|
||||
│ ├── app_update/ ← 업데이트 화면 (모듈이 아니다 — 서버의 최소 판보다 낮을 때)
|
||||
│ ├── auth/ ← 세션 · 앱 로그인 (모듈이 아니다 — 모든 모듈이 쓴다)
|
||||
│ ├── mail/ ← 모듈 — 주소 /mail/...
|
||||
│ │ ├── mail_routes.dart ← 이 모듈의 라우트 묶음 (core/router 가 모은다)
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── models/ ← JSON 모델 (손으로 — 위 "모델 생성기")
|
||||
│ │ │ └── repositories/ ← API 호출 구현체
|
||||
│ │ ├── domain/
|
||||
│ │ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ │ └── presentation/
|
||||
│ │ ├── pages/ ← 화면 단위 위젯
|
||||
│ │ ├── widgets/ ← 재사용 위젯
|
||||
│ │ └── providers/ ← 상태 관리
|
||||
│ ├── settings/ ← 모듈 — 주소 /settings/... (다른 모듈의 설정 절을 받아 붙인다)
|
||||
│ └── user/ ← 예제(참조용)
|
||||
└── shared/
|
||||
└── widgets/ ← 앱 공통 위젯 (생성 완료)
|
||||
└── widgets/ ← 앱 공통 위젯 (자리 화면 · 확인 창 · 불러오기 상태 틀 등)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -133,103 +172,109 @@ Expanded(flex: 1, child: RightPanel()),
|
||||
|
||||
## 상태 관리
|
||||
|
||||
```dart
|
||||
// Riverpod 예시 (권장)
|
||||
@riverpod
|
||||
Future<User> fetchUser(FetchUserRef ref, int id) async {
|
||||
final repo = ref.watch(userRepositoryProvider);
|
||||
return repo.getUser(id);
|
||||
}
|
||||
생성기(`riverpod_generator` · `@riverpod`)는 쓰지 않는다 — 프로바이더는 타입을 적어 손으로 만든다(위 "모델 생성기").
|
||||
|
||||
```dart
|
||||
// 프로바이더 — 타입을 적는다 (features/user 예제 · features/auth 와 같은 꼴)
|
||||
final Provider<UserRepository> userRepositoryProvider = Provider<UserRepository>(
|
||||
(Ref ref) => UserRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
|
||||
final FutureProviderFamily<UserModel, int> userProvider =
|
||||
FutureProvider.family<UserModel, int>(
|
||||
(Ref ref, int id) => ref.watch(userRepositoryProvider).getUser(id),
|
||||
);
|
||||
|
||||
// 위젯에서 사용 — 실패는 [다시 시도](ref.invalidate)로 다시 한다(저절로 다시 하지 않는다)
|
||||
class UserTile extends ConsumerWidget {
|
||||
const UserTile({required this.userId, super.key});
|
||||
|
||||
final int userId;
|
||||
|
||||
// 위젯에서 사용
|
||||
class UserWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userAsync = ref.watch(fetchUserProvider(userId));
|
||||
return userAsync.when(
|
||||
data: (user) => Text(user.name),
|
||||
final AsyncValue<UserModel> user = ref.watch(userProvider(userId));
|
||||
return user.when(
|
||||
data: (UserModel value) => Text(value.name),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => const ErrorWidget(),
|
||||
error: (Object error, StackTrace _) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Riverpod** 권장 (Provider는 레거시로 간주)
|
||||
- **Riverpod 3** — 세션처럼 바꾸는 일이 있는 상태는 `AsyncNotifier`/`Notifier`(`sessionProvider` 가 본보기)
|
||||
- **저절로 다시 하기는 끈다**: Riverpod 3 은 프로바이더의 `retry` 를 주지 않으면 실패한 프로바이더를 기본으로 10번까지 다시 만든다(요청을 다시 보낸다).
|
||||
앱은 ProviderScope(main.dart) · 시험의 ProviderContainer 에 `noAutomaticRetry`(`core/utils/provider_retry.dart`)를 건다 — 다시 하기는 화면의 [다시 시도]가 한다
|
||||
- `StatefulWidget`은 애니메이션, 폼 등 로컬 UI 상태에만 제한적 사용
|
||||
- 실패를 상태(`AsyncValue`)에 두어도 된다 — 비밀이 든 요청(`ApiOptions.secret` — 로그인 · 코드 · 비밀번호 단계 · logout)의 실패 예외는 본문을 비워 던지고, 인증 인터셉터가 내보내는 모든 실패 예외는 Bearer 를 지운 채 온다
|
||||
|
||||
---
|
||||
|
||||
|
||||
## API 통신 (Dio + 인터셉터)
|
||||
|
||||
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
|
||||
앱 로그인은 앱 전용 토큰 경로다(계약 `docs/mail/api-contract.md` 1절 "앱 로그인" — `POST /api/auth/app/login` · `/app/refresh` · `/app/logout`, 보호 API 는 `Authorization: Bearer`).
|
||||
세션 규칙(1회용 회전 · 30초 유예 · 끊은 세션 바로 막기 · 제한된 세션)은 웹과 같고 토큰을 나르는 길만 다르다.
|
||||
|
||||
```
|
||||
화면 · 저장소 ── ref.watch(dioProvider)
|
||||
│
|
||||
▼ 인터셉터 줄 (core/api/dio_client.dart)
|
||||
ResponseInterceptor {success,data}→data · 실패는 DioException.error = ApiException
|
||||
AuthInterceptor QueuedInterceptor — Bearer · 미리 갱신 · 401 → TokenRefresher(한 번에 하나) → 한 번 다시 · AUTH_010 → 다시 맞추기(renewed 면 한 번 다시)
|
||||
ErrorNotifyInterceptor 맨 끝 — silent 가 아니면 ErrorNotifier(스낵바 · 같은 코드 3초)
|
||||
│ 갱신 · 다시 보내기는 언래핑만 있는 안쪽 Dio 로 (같은 줄을 다시 타지 않게)
|
||||
▼
|
||||
SessionEventBus ──▶ 세션 노티파이어 (세션 끝 → 로그인 화면 · AUTH_010 → 세션 확인(안쪽 Dio) · 갱신 → RestrictionCheck 로 답 · 갱신 응답의 제한)
|
||||
```
|
||||
|
||||
```dart
|
||||
// core/api/dio_client.dart (실제 구조 요약)
|
||||
class DioClient {
|
||||
DioClient._internal() {
|
||||
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
|
||||
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// jwt_token 읽어 Authorization 헤더 첨부
|
||||
final token = await _storage.read(key: kJwtTokenKey);
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
// success==true → data 언래핑, false → ApiException 으로 reject
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (err, handler) async {
|
||||
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
|
||||
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
|
||||
handler.next(err.copyWith(error: await _toApiException(err)));
|
||||
},
|
||||
));
|
||||
}
|
||||
// 저장소 — 파싱만 한다. 문구를 만들지 않는다
|
||||
final Response<Map<String, Object?>> response = await _dio.get<Map<String, Object?>>(
|
||||
'/mail-state',
|
||||
options: ApiOptions.silent(), // 요청마다 알림 끄기 — Options(extra: {kSilentExtraKey: true}) 와 같다
|
||||
);
|
||||
|
||||
// 화면 — code 로 가른다
|
||||
try {
|
||||
await ref.read(sessionProvider.notifier).signIn(email: email, password: password);
|
||||
} on DioException catch (e) {
|
||||
if (apiErrorCodeOf(e) == 'AUTH_004') showTopError();
|
||||
}
|
||||
```
|
||||
|
||||
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
|
||||
- 인터셉터를 더할 때는 위 차례를 지킨다 — 알림은 늘 맨 끝(갱신 · 다시 보내기가 끝난 결과만 알린다).
|
||||
- 세션 노티파이어를 인터셉터가 직접 부르지 않는다(노티파이어가 Dio 를 쓰므로 서로 물린다) — `SessionEventBus` 로 알린다.
|
||||
- 갱신을 새로 부르는 곳(제한을 푼 직후 등)도 `TokenRefresher`(`AuthRepository.refresh`)를 지난다 — 두 갱신이 겹치면 늦은 쪽이 "이미 쓴 토큰"이다.
|
||||
|
||||
---
|
||||
|
||||
## 보안 (토큰 저장)
|
||||
|
||||
```dart
|
||||
// flutter_secure_storage 사용
|
||||
const storage = FlutterSecureStorage();
|
||||
|
||||
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
|
||||
await storage.write(key: kJwtTokenKey, value: token);
|
||||
|
||||
// 읽기
|
||||
final token = await storage.read(key: kJwtTokenKey);
|
||||
|
||||
// 삭제 (로그아웃)
|
||||
await storage.deleteAll();
|
||||
// 토큰은 TokenStore 로만 다룬다 (core/storage/token_store.dart)
|
||||
await tokenStore.save(tokens); // 리프레시 토큰 → 보안 저장소(kRefreshTokenKey), 액세스 토큰 → 메모리
|
||||
final String? refresh = await tokenStore.readRefreshToken();
|
||||
await tokenStore.clear(byUser: true); // 로그아웃 (세션 끝은 byUser 없이 — 늦은 401 알림을 가른다)
|
||||
```
|
||||
|
||||
- JWT, 민감 정보는 반드시 `flutter_secure_storage` 사용
|
||||
- 키 문자열을 여기저기 적지 말고 `kJwtTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences`에 토큰 저장 **금지**
|
||||
- 리프레시 토큰 · 민감 정보는 `flutter_secure_storage`(`SecureStore`)에만, 액세스 토큰은 메모리에만 둔다
|
||||
- 키 문자열을 여기저기 적지 말고 `kRefreshTokenKey` 상수를 쓴다 ([[coding-common]])
|
||||
- `SharedPreferences` 에 토큰 저장 **금지** — 첫 실행 표시(참 · 거짓 하나)만 둔다
|
||||
- 로그인 때 넣은 비밀번호는 Riverpod 상태에 두지 않는다(`LoginSecret` — 메모리 · 10분)
|
||||
- 비밀번호 · 토큰은 로그 · 예외 문장에 남기지 않는다 — `LogInterceptor` 를 넣지 않고, `AuthTokens.toString()` 은 값을 가린다
|
||||
- 추가 보안 규칙은 [[security]] 참조.
|
||||
|
||||
---
|
||||
|
||||
## 성능 & 최신 트렌드 추가 규칙
|
||||
|
||||
- **모델 직렬화**: `json_serializable` + `freezed` 사용 권장 (불변 모델)
|
||||
- **모델 직렬화**: 지금은 손으로 쓴 불변 모델(`final` 칸 + `fromJson`/`tryParse` — `features/auth/data/models`). `json_serializable` · `freezed` 는 위 "모델 생성기" 주의를 지킨 뒤에 넣는다
|
||||
- **이미지**: `cached_network_image`로 네트워크 이미지 캐싱
|
||||
- **목록**: 긴 목록은 `ListView.builder` 사용 (전체 렌더링 금지)
|
||||
- **에러 핸들링**: `Either<Failure, Success>` 패턴 고려 (`fpdart` 패키지)
|
||||
- **라우팅**: `go_router` 사용 권장 (딥링크, 중첩 라우팅 지원)
|
||||
- **라우팅**: `go_router` (도입 완료 — `core/router/`). 새 화면은 그 모듈의 라우트 묶음에 더한다
|
||||
- **환경 분리**: `--dart-define` 또는 `flutter_dotenv`로 dev/prod 분리
|
||||
|
||||
---
|
||||
@@ -241,17 +286,37 @@ await storage.deleteAll();
|
||||
- [ ] **Null Safety**: `dynamic` 금지, `?`/`!`/`late` 정확히 사용.
|
||||
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
|
||||
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
|
||||
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
|
||||
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
|
||||
- [ ] **API**: `dioProvider` 의 Dio 를 쓴다(새 클라이언트 · 인터셉터 차례를 만들지 않는다). 리포지토리에서 try/catch 로 메시지를 만들지 않는다. 화면이 칸에 보이는 실패는 `silent` 로 끈다.
|
||||
- [ ] **에러 분기**: `apiErrorCodeOf(e)`(또는 `e.error as ApiException`)의 **`code` 로 분기**한다. 문구는 사전에서만 온다. `AUTH_010` 은 화면이 다루지 않는다(세션이 그 단계로 옮긴다).
|
||||
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
|
||||
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
|
||||
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
|
||||
- [ ] **토큰**: `TokenStore` 로만(리프레시 = `flutter_secure_storage` 의 `kRefreshTokenKey`, 액세스 = 메모리). `SharedPreferences` 저장 금지.
|
||||
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
|
||||
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
|
||||
- [ ] **권장 패키지**: 모델(`freezed`/`json_serializable`)·라우팅(`go_router`) 도입 시 `pubspec.yaml` 추가 후 `flutter pub get`.
|
||||
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
- [ ] **모듈**: 새 기능은 그 모듈 폴더(`features/<모듈>/`)와 라우트 묶음 안에. 모듈끼리 import 하지 않는다 — 함께 쓰는 것은 `core/` · `shared/`. 모듈 목록을 바꾸면 웹 `app-modules.ts` 도(짝).
|
||||
- [ ] **화면**: 캔버스로 확인받은 뒤 만든다 — 그때까지 자리 화면(`PlaceholderPage`). 글자 크기는 `textTheme` 역할 이름으로만, 누르는 곳은 48dp 이상.
|
||||
- [ ] **권장 패키지**: 새 패키지는 배포 2주가 지난 판만, `pubspec.yaml` 추가 후 `flutter pub get`. 모델 생성기를 넣을 때는 위 "모델 생성기" 주의(build.yaml `generate_for`).
|
||||
- [ ] **analyze/test**: 커밋 전 `flutter analyze` (Warning 0) + `flutter test` 통과. 배포 판(release)은 우리 서명 키(`android/key.properties` — 저장소 밖 keystore)와 `google-services.json` 이 있고 `flutter/.env` 의 `API_URL` 이 운영 포털의 https 주소일 때만 만든다 — 하나라도 아니면 Gradle 이 까닭을 알리고 멈춘다(debug 키로 조용히 서명하지 않고, 개발 주소가 든 판이 나가지 않게 — 알리는 글에 주소 값은 싣지 않는다, `flutter/README.md` "사내 배포"). 만들면 release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다는 것도 본다.
|
||||
Kotlin(`MainActivity.kt`)을 고쳤으면 커밋 전에 `flutter build apk --debug` 로 컴파일을 본다 — CI 는 안드로이드를 빌드하지 않고,
|
||||
Kotlin 쪽 짝(고른 파일의 미리 거르기 · 이름 규칙)에는 시험이 없다(Dart 가 판정을 다시 하고, 어긋남은 앱 README 의 실기기 목록으로 본다).
|
||||
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 와 앱 짝 시험이 읽는 백엔드 파일 둘(`attachment.policy.ts` · `test/search-text-samples.ts`)이 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
|
||||
- [ ] **위젯 · 시험 함정**(쓰기 · 검색 화면에서 찾음 — 2026-11-07):
|
||||
`TextEditingController` 는 초점 · 선택만 바뀌어도 알린다 — 바꿈은 글을 견줘 가른다.
|
||||
go_router 의 `context.pop()` 은 `NavigatorState.pop` 이라 PopScope 의 `canPop` 을 보지 않는다 — 화면이 스스로 닫을 때는 자기 표시로 가린다.
|
||||
`ref` 는 `State.dispose` 에서 쓰지 못한다 — 노티파이어를 필드에 쥔다(`initState` 에서 `listenManual` + `read`).
|
||||
되풀이 타이머(남은 시간 막대 250ms)는 `pumpAndSettle` 간격(100ms)보다 길게 둔다. isolate 를 쓰는 일(원문 인용)은 위젯 시험에서 프로바이더로 덮어쓴다(`composeQuoteBuilderProvider`).
|
||||
소스에 보이지 않는 글자를 두지 않는다 — nbsp 는 `'\u00A0'` 이스케이프로 적는다.
|
||||
기기 파일 입출력(고르기 · 복사본)은 시험 시계 안에서 끝나지 않는다 — `PickedFileStore` 를 덮어쓴다(`FakePickedFiles`).
|
||||
무한 진행 막대(고르는 동안)가 떠 있으면 `pumpAndSettle` 이 끝나지 않는다 — 그동안은 `pump()` 로 넘긴다.
|
||||
가짜 서버는 요청 본문을 읽지 않아 보내는 진행(onSendProgress)이 오지 않는다 — 진행을 보려면 `FakeServer.readRequestBodies` 를 켠다.
|
||||
InkWell 에 길게 누르기 처리가 없으면 길게 눌렀다 뗄 때 onTap 이 돈다 — 열지 않을 줄은 길게 누르기를 받아 두고 아무것도 하지 않는다(검색 결과 줄).
|
||||
두 줄 TextField 에도 Scrollable 이 있다 — 목록 스크롤은 ListView 아래에서 찾는다(`find.descendant`).
|
||||
go_router 17 의 `context.pushReplacement` · `replace` 는 void 다(`push` 만 Future) — 바꿔 끼울 화면은 `pushReplacement`(새 page key — 앞 화면의 PageStorage 스크롤 자리를 물려받지 않는다, 업데이트 내역의 다른 글 줄).
|
||||
짜임을 둘로 가르는 화면(읽기 화면의 본문 칸 있음 · 없음)은 두 짜임에 같은 자리의 같은 위젯을 두고 값(`ConstrainedBox` 의 maxHeight)만 바꾼다 — `Flexible` · `Expanded` 로 바꿔 끼우면 그 아래 State(펼친 받는 사람 등)가 새로 만들어진다. 시험은 위치로 본다(머리 스크롤 칸의 아래 끝 = 아래 막대의 위 끝).
|
||||
`InputDecoration.collapsed` 는 테두리 없는 칸이 아니다 — `border` 만 없애고 `enabledBorder` · `focusedBorder` 등을 비워 두면 TextField 가 그 칸을 테마의 `inputDecorationTheme`(둥근 테두리 · 초점은 청록 2)로 채우고, InputDecorator 는 `border` 보다 그 칸들을 먼저 쓴다(2026-11-12 — 쓰기 화면의 칸마다 글자를 감싼 테두리). 칸 줄이 선을 따로 그리는 칸은 `borderlessInputDecoration`(`core/theme/app_theme.dart` — 여섯 테두리 모두 없음 · 여백 0)을 쓴다. `lib/` 에 `InputDecoration.collapsed(` 가 들어오면 시험(`test/core/theme/borderless_input_decoration_test.dart`)이 실패한다. 칸의 테두리는 `test/support/input_borders.dart` 의 `expectBorderlessInput`(평소 · 초점 · 꺼짐마다 InputDecorator 가 받은 꾸밈)으로 본다.
|
||||
화면을 그림으로 견줄 때(임시 시험 — 저장소에 남기지 않는다): `setUpAll` 에서 `FontLoader('Pretendard')` 에 `assets/fonts/Pretendard-*.otf` 넷을, `FontLoader('MaterialIcons')` 에 SDK 의 `bin/cache/artifacts/material_fonts/materialicons-regular.otf` 를 올리고(그러지 않으면 글자가 네모), 맨 위 repaint boundary(RenderView)의 `OffsetLayer.toImage(paintBounds)` 를 `tester.runAsync` 안에서 PNG 로 쓴다. 화면 크기는 `MailAppHarness.pump(size:)`.
|
||||
화면 아래 끝까지 닿는 스크롤(읽기 화면의 빈 본문 + 아래 막대 없는 예약 메일 등)은 `MediaQuery.paddingOf(context).bottom` 을 스크롤 끝 여백으로 더한다 — Scaffold 는 bottomNavigationBar 가 있을 때만 몸통의 아래 padding 을 0 으로 만든다. 시험은 `tester.view.padding = FakeViewPadding(bottom: 48 * 3)`(물리 픽셀 — 시험대는 3배) + `addTearDown(tester.view.resetPadding)`.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
|
||||
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,16 +26,25 @@ REDIS_PORT=6379
|
||||
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail(·mailpit) — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend ↔ backend 전용 (frontend 는 app-network 에 붙지 않는다)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS 와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
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
|
||||
@@ -86,6 +95,8 @@ DB_SYNCHRONIZE=false
|
||||
# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail
|
||||
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis
|
||||
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend
|
||||
# clamav 대용량 첨부의 악성 검사기(ClamAV — 메모리 1GB 남짓 · 처음에 서명 110MB 를 받는다)
|
||||
# → COMPOSE_PROFILES=mail,clamav 와 함께 아래 "대용량 첨부" 의 CLAMAV_HOST=clamav (운영은 profile 없이 늘 뜬다)
|
||||
#
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
#
|
||||
@@ -103,6 +114,36 @@ COMPOSE_PROFILES=mail
|
||||
UPLOAD_DATA_DIR=./data/uploads
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)")
|
||||
#
|
||||
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 메일 엔진이 아니라 백엔드의 볼륨(<PROJECT_NAME>_files — 개발도 named volume)에 올리고,
|
||||
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다 — 비워 두어도 그 값이다.
|
||||
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처(스킴 · 호스트[· 포트] — 경로 · 끝의 / 없이). 개발은 비운다 = CORS_ORIGIN 의 첫 주소(포털과 같은 호스트).
|
||||
# 운영은 포털과 다른 호스트다(.env.production.example)
|
||||
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
|
||||
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
|
||||
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
|
||||
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
|
||||
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
|
||||
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
|
||||
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다
|
||||
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 개발은 Docker Desktop 가상 디스크의 여유를 본다
|
||||
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd). 개발은 비워 둔다 = 검사하지 않고 다 올리자마자 "검사하지 못함".
|
||||
# 켜려면 COMPOSE_PROFILES=mail,clamav 와 함께 CLAMAV_HOST=clamav (compose 의 clamav 서비스, 포트 3310)
|
||||
# ==========================================
|
||||
SHARE_PUBLIC_URL=
|
||||
SHARE_REPORT_ADDRESS=
|
||||
SHARE_DOWNLOAD_LIMIT=500
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
|
||||
MAIL_LARGE_FILE_MAX_COUNT=10
|
||||
MAIL_LARGE_FILE_USER_QUOTA_GB=10
|
||||
MAIL_LARGE_FILE_RETENTION_DAYS=30
|
||||
FILE_STORAGE_MAX_TOTAL_GB=
|
||||
FILE_STORAGE_MIN_FREE_GB=10
|
||||
CLAMAV_HOST=
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
@@ -147,6 +188,10 @@ MAIL_GATEWAY_PASSWORD=change-me-in-local-env
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로 서명하고(HMAC-SHA256)
|
||||
# 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다. 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다)
|
||||
# 비우면 setup 이 웹훅을 두지 않는다(개발만 — 그동안 메일 프로그램 로그인 기록이 남지 않는다). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=changemeinlocalenv00000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
|
||||
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
|
||||
MAIL_ATTACHMENT_MAX_SIZE_MB=20
|
||||
@@ -159,6 +204,16 @@ MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test
|
||||
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
|
||||
# 개발은 비워 두고, 시험할 때만 셸에서 일회성으로 준다 (기존 서버 역 = Mailpit — 개발에서는 셸 값을 일부러 받는다):
|
||||
# MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs
|
||||
# 값 없이 다시 실행하면 setup 이 시범 설정을 지운다. 기존 서버 주소 — host · host:port · [host]:port (포트 기본 25)
|
||||
MAIL_PILOT_LEGACY_HOST=
|
||||
# 기존 서버가 이 서버에 붙을 때의 IPv4 · CIDR (콤마). 개발은 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳)
|
||||
MAIL_PILOT_LEGACY_IPS=
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 키는 운영 서버의 .env.production 에만 둔다 — 개발은 비워 둔다.
|
||||
# 비어 있으면 setup 이 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)을 꺼 둔다 — 개발도 운영과 같다(공개 미러는 소규모 · 비상업 용도다)
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
|
||||
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
|
||||
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
|
||||
|
||||
+88
-3
@@ -23,16 +23,26 @@ REDIS_PORT=6379
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다.
|
||||
# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS(setup 이 맞춘다)와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# 서버의 사설망(NCP VPC 등)과도 겹치지 않게 한다 — 겹치면 그 대역으로 가는 길이 도커 브리지로 간다 (mail/README.md "NCP 쪽 할 일")
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
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
|
||||
@@ -59,16 +69,20 @@ REDIS_DATA_DIR=./data/redis
|
||||
#
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||||
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
|
||||
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
|
||||
# (프론트 nginx 는 TLS 를 하지 않고 호스트 포트도 열지 않으므로(다른 서버의 프록시용 구성은 사설 IP 에만) 외부 프록시 없이는 쓸 수 없다 — 1 은 해당 없음)
|
||||
# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
|
||||
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를
|
||||
# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른
|
||||
# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다.
|
||||
# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등).
|
||||
# 넓은 사설 대역을 통째로 넣지 않는다.
|
||||
# 외부 프록시가 다른 서버에 있으면(아래 "리버스 프록시가 다른 서버에 있을 때") 주소 목록으로 —
|
||||
# <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP> (예: 172.16.72.0/24,10.0.1.11). 사설 IP 는 frontend 접근 로그의 첫 칸이다
|
||||
# true·0·false 는 운영에서 기동이 거부된다.
|
||||
# ==========================================
|
||||
BACKEND_API_URL=https://portal.arasystem.it.kr/api
|
||||
# ⚠️ 대용량 첨부의 공개 내려받기 호스트(아래 SHARE_PUBLIC_URL — files.)는 여기에 넣지 않는다 — 넣으면 공개 호스트 분리가 꺼져
|
||||
# 포털에서도 공개 링크가 열린다(백엔드가 운영에서는 기동을 막는다). 공개 화면은 자기 호스트의 /api/public-links/ 만 부른다
|
||||
CORS_ORIGIN=https://portal.arasystem.it.kr
|
||||
TRUST_PROXY=2
|
||||
|
||||
@@ -92,13 +106,30 @@ DB_SYNCHRONIZE=false
|
||||
# 선택적 서비스 (Compose profiles)
|
||||
#
|
||||
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
|
||||
# (redis 는 기동하지 않는다)
|
||||
# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부")
|
||||
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
|
||||
# COMPOSE_PROFILES=mail,redis
|
||||
# 일회성으로 켜려면: docker compose --profile redis ... up -d
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (선택 — mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본은 외부 리버스 프록시가 이 호스트에서 external proxy 네트워크로 <PROJECT_NAME>-frontend:8080 에 붙는 구성이다.
|
||||
# 다른 서버의 프록시(예: 같은 VPC 의 Nginx Proxy Manager)가 사설망으로 붙는다면 COMPOSE_FILE 의 주석을 풀고 FRONTEND_PRIVATE_BIND 를 채운다 —
|
||||
# docker-compose.remote-proxy.yml 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 사설 IP 에만 게시한다.
|
||||
# COMPOSE_FILE : compose 파일 목록 (리눅스는 : 로 잇는다). 이 파일에 두면 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# -f 없이 그대로 두 파일을 읽는다 (compose v2.40 · 5.4 에서 확인). 쓰지 않을 때는 주석으로 둔다 —
|
||||
# 빈 값(COMPOSE_FILE=)은 compose 가 파일 목록으로 읽다 실패한다
|
||||
# FRONTEND_PRIVATE_BIND : <이 서버의 사설 IPv4>:<포트> (예: 10.0.1.12:8080 — ip -4 addr 로 본다).
|
||||
# 비어 있으면 compose 가 멈추고, 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 면 frontend 가 뜨지 않는다 (frontend-bind-check)
|
||||
# TRUST_PROXY 는 위 "화면·API 주소" 의 주소 목록으로 바꾼다 — <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>
|
||||
# ⚠️ 게시 주소만으로는 바깥을 막지 못한다 — NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 8080 은 방화벽(ACG)이 프록시 서버의 사설 IP 에서만 받게 한다
|
||||
# ==========================================
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
FRONTEND_PRIVATE_BIND=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
@@ -108,6 +139,38 @@ COMPOSE_PROFILES=mail
|
||||
# ==========================================
|
||||
UPLOAD_MAX_SIZE_MB=5
|
||||
|
||||
# ==========================================
|
||||
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)" · "대용량 첨부 — 운영")
|
||||
#
|
||||
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 백엔드의 볼륨(<PROJECT_NAME>_files — 30일짜리 전송물이라 백업하지 않는다)에 올리고,
|
||||
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다(SHARE_PUBLIC_URL · CLAMAV_HOST 는 운영 필수).
|
||||
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처 — **운영 필수**, https, 경로 · 끝의 / 없이. 포털과 다른 호스트로 둔다:
|
||||
# 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지 차단 목록에 오르는 위험을 떼어 놓고, 포털 쿠키도 그 호스트로 가지 않는다.
|
||||
# DNS(가비아 files A → 외부 리버스 프록시 서버) · 외부 프록시의 Proxy Host(같은 사설 IP:8080, 접근 로그 끔)가 있어야 한다.
|
||||
# ⚠️ 이 호스트를 위 CORS_ORIGIN 에 넣지 않는다(넣으면 분리가 꺼진다 — 백엔드가 기동을 막는다).
|
||||
# ⚠️ 한 번 정하면 바꾸지 않는다 — 이미 보낸 메일의 링크가 이 주소다(옛 호스트가 남아 있어야 열린다)
|
||||
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
|
||||
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
|
||||
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
|
||||
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
|
||||
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
|
||||
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
|
||||
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다. 디스크를 메일 · DB 와 함께 쓰면 정해 둔다
|
||||
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 도커 data-root 의 디스크(볼륨 · 악성 검사의 임시 파일이 함께 쓴다)
|
||||
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd) — **운영 필수**. compose 의 clamav 서비스(호스트 이름만 — 스킴 · 포트 없이)
|
||||
# ==========================================
|
||||
SHARE_PUBLIC_URL=https://files.arasystem.it.kr
|
||||
SHARE_REPORT_ADDRESS=
|
||||
SHARE_DOWNLOAD_LIMIT=500
|
||||
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
|
||||
MAIL_LARGE_FILE_MAX_COUNT=10
|
||||
MAIL_LARGE_FILE_USER_QUOTA_GB=10
|
||||
MAIL_LARGE_FILE_RETENTION_DAYS=30
|
||||
FILE_STORAGE_MAX_TOTAL_GB=
|
||||
FILE_STORAGE_MIN_FREE_GB=10
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
# ==========================================
|
||||
# 메일 엔진 (Stalwart) — profile `mail`
|
||||
#
|
||||
@@ -157,6 +220,12 @@ MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
|
||||
MAIL_DIRECTORY_LOCAL_PART=mail-directory
|
||||
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
|
||||
MAIL_JMAP_URL=http://mail:8080
|
||||
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — **운영 필수**. 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로
|
||||
# 서명하고(HMAC-SHA256) 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다 — 비었거나 CHANGEME 가 남아 있으면 둘 다 멈춘다.
|
||||
# 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다 — 엔진 관리 화면에 이 키의 지문이 보인다)
|
||||
# 새 이미지를 띄우기(up) 전에 넣는다 — 없으면 백엔드가 env 검사에서 멈춘다. 바꾸면 backend 를 다시 띄우고(up -d backend) setup 을 다시 실행한다 —
|
||||
# 그 사이에 엔진이 보낸 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅")
|
||||
MAIL_WEBHOOK_SECRET=CHANGEMEBEFOREDEPLOY000000000000
|
||||
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
|
||||
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
|
||||
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
|
||||
@@ -170,3 +239,19 @@ MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 있으면 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)이 공개 미러 대신
|
||||
# Spamhaus 의 "Blocklists via DNS Query"(옛 DQS — <키>.zen.dq.spamhaus.net)로 묻는다. 비어 있으면 setup 이 두 항목을 끄고 [주의] 를 낸다
|
||||
# (공개 미러는 소규모 · 비상업 용도라 쓰지 않는다 — 나머지 차단 목록은 그대로).
|
||||
# 무료 계정은 Spamhaus 의 Fair Use(소규모 · 비상업) 조건이다 — 회사가 대상인지는 Spamhaus 가 판단하고, 안 되면 비워 두거나(두 항목 꺼짐) 유료(30일 체험)로.
|
||||
# ⚠️ 체험(30일) 키는 기간이 끝나면 답이 멈춘다 — 구독하거나 키를 비우고 setup. 멈추면 엔진의 Spamhaus 조회가 실패해 표시가 붙지 않고(메일은 받는다)
|
||||
# 다음 setup 실행이 두 항목을 끄고 종료 1 로 알린다 — setup 을 주기적으로 돌려 종료 0 인지 보거나 Spamhaus 계정의 만료일을 본다.
|
||||
# 키(26자 영문 · 숫자)는 비밀이다 — 이 파일에만 둔다(파일에 없거나 파일과 다른 값이 셸에 있으면 setup 이 멈춘다). setup 은 출력하지 않지만 엔진의 스팸 필터 설정(조회 이름 식)에는
|
||||
# 그대로 저장된다 — 관리 화면 · 관리 API · 메일 볼륨 백업으로 보인다. 바꾸거나 비우면 setup 을 다시 실행한다
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
|
||||
# MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소만 이 서버로 넘긴다. 값은 셸이 아니라 이 파일에 둔다.
|
||||
# ⚠️ 켠 뒤에 값이 비면 setup 이 멈춘다 — 시범을 끝낼 때(전면 전환)는 둘 다 비우고 setup 에 --end-pilot 을 붙인다.
|
||||
# 기존 서버 주소 — host · host:port · [host]:port (운영은 포트 25 만 — 기본값, IPv4 주소면 인증서 이름 확인을 끈다). 예: mail.arasystem.it.kr
|
||||
MAIL_PILOT_LEGACY_HOST=
|
||||
# 기존 서버가 이 서버에 붙을 때의 공인 IPv4 · CIDR(/24 까지, 콤마) — HOST 가 있으면 필수. 예: 46.250.248.43
|
||||
MAIL_PILOT_LEGACY_IPS=
|
||||
|
||||
@@ -7,6 +7,8 @@ name: CI (Flutter)
|
||||
#
|
||||
# 언제 도는가:
|
||||
# - flutter/ 아래 파일이 바뀐 푸시·PR (= 실제로 Flutter 를 쓸 때 자동으로)
|
||||
# - 앱 짝 시험이 그대로 읽는 백엔드 파일이 바뀐 푸시·PR — 첨부 형식 표(attachment.policy.ts) · 검색 표본 표(search-text-samples.ts).
|
||||
# 그 파일만 고친 커밋에서도 앱 시험이 깨지는지 본다 (2026-11-07)
|
||||
# - Actions 탭의 "Run workflow" 버튼으로 수동 실행
|
||||
#
|
||||
# 모바일 앱을 아예 쓰지 않는 프로젝트라면 이 파일을 지우면 된다.
|
||||
@@ -16,10 +18,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'flutter/**'
|
||||
- 'backend/src/modules/mail/attachment.policy.ts'
|
||||
- 'backend/test/search-text-samples.ts'
|
||||
- '.github/workflows/ci-flutter.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flutter/**'
|
||||
- 'backend/src/modules/mail/attachment.policy.ts'
|
||||
- 'backend/test/search-text-samples.ts'
|
||||
- '.github/workflows/ci-flutter.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -39,16 +45,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Flutter 판 고정 — 개발 PC 와 같은 판이다(.claude/skills/flutter/SKILL.md "Flutter 판").
|
||||
# 떠 있는 stable 을 받으면 그날의 최신이 들어와 개발 PC 와 어긋난다. 올리는 것은 사용자가 정한다.
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: 3.41.9
|
||||
cache: true
|
||||
|
||||
# .env 는 pubspec 의 asset 이라 없으면 analyze 가 경고를 낸다
|
||||
- name: Prepare .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- run: flutter pub get
|
||||
# lock 그대로 받는다 — pubspec 과 어긋나면 실패(조용히 다른 판을 받지 않게)
|
||||
- run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
+359
-3
@@ -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
|
||||
@@ -139,6 +139,52 @@ jobs:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
|
||||
docker compose -f docker-compose.yml --env-file .env.production.example config -q
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (docker-compose.remote-proxy.yml — mail/README.md). 게시 주소는 이 단계에서만 준다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q
|
||||
# proxy 네트워크가 빠져야 한다 — 남으면 그 서버에 없는 external 네트워크를 찾다 up 이 멈춘다
|
||||
# (grep -q 는 쓰지 않는다 — 먼저 끝나면 pipefail 셸에서 compose 의 SIGPIPE 가 결과를 뒤집는다)
|
||||
if FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config --networks | grep -x proxy >/dev/null; then
|
||||
echo "::error::docker-compose.remote-proxy.yml 이 proxy 네트워크를 빼지 못했습니다"; exit 1
|
||||
fi
|
||||
# 게시 주소가 없으면 멈춰야 한다 (예시 env 의 FRONTEND_PRIVATE_BIND 는 비어 있다 — 빈 값이면 모든 주소에 열린다)
|
||||
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 프로젝트와 섞이지 않게 한다
|
||||
- name: frontend 게시 주소 확인 (리버스 프록시가 다른 서버에 있을 때)
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
check() {
|
||||
FRONTEND_PRIVATE_BIND="$1" docker compose -p "ci-bind-${{ github.run_id }}" \
|
||||
-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example \
|
||||
run --rm --no-deps -T frontend-bind-check
|
||||
}
|
||||
for value in 10.0.1.12:8080 172.31.255.254:65535 192.168.0.10:18080; do
|
||||
check "$value" || { echo "::error::FRONTEND_PRIVATE_BIND=$value 를 거부했습니다 (받아야 한다)"; exit 1; }
|
||||
done
|
||||
for value in 8080 :8080 0.0.0.0:8080 127.0.0.1:8080 203.0.113.5:8080 172.32.0.1:8080 '[::]:8080' 10.0.1.12:0 10.0.1.12:08080; do
|
||||
if check "$value" 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Dockerfile 검사
|
||||
run: |
|
||||
@@ -150,18 +196,328 @@ jobs:
|
||||
run: |
|
||||
tag="ci-nginx-config:${{ github.run_id }}"
|
||||
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
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
|
||||
# 따로 둘 때 — 공개 내려받기 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;' \
|
||||
'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)
|
||||
for form in plain rendered; do
|
||||
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
|
||||
# 앱 안 받기 · 휴대폰 앱 받기(2026-11-08)가 같은 그릇(download_app_*)을 쓴다 — 포털의 두 location 에 하나씩, 두 꼴 모두 둘이다
|
||||
for want in 'download_app_ip 30;' 'download_app_total 200;'; do
|
||||
n=$(printf '%s\n' "$conf" | awk -v want="$want" '$1 == "limit_conn" && ($2 " " $3) == want { c++ } END { print c + 0 }')
|
||||
[ "$n" -eq 2 ] || { echo "::error::limit_conn $want 이 든 location 이 둘(앱 안 받기 · 휴대폰 앱 받기)이 아닙니다 ($form: $n)"; exit 1; }
|
||||
done
|
||||
done
|
||||
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소)
|
||||
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
|
||||
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then
|
||||
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do
|
||||
if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then
|
||||
echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 ·
|
||||
# X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말).
|
||||
# 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로,
|
||||
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다.
|
||||
# 휴대폰 앱(APK) 받기(/api/app-releases/…/file — 2026-11-08)도 본다: 같은 변형이 받기 location 을 타고 앱 안 받기와 그릇(주소마다 30)을 함께 쓰는지,
|
||||
# 공개 호스트는 404 로 닫는지, 정보 경로(…/latest — JSON)는 받기 location 밖인지
|
||||
- name: 대용량 받기 경로 시험
|
||||
run: |
|
||||
base="ci-nginx-config:${{ github.run_id }}"
|
||||
tag="ci-nginx-paths:${{ github.run_id }}"
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
ctx=$(mktemp -d)
|
||||
cat > "$ctx/paths.sh" <<'EOF'
|
||||
#!/bin/sh
|
||||
P=http://127.0.0.1:8080
|
||||
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
||||
F=11111111-2222-4333-8444-555555555555
|
||||
fails=0
|
||||
# host path → "상태 xab|-"
|
||||
probe() {
|
||||
timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk '
|
||||
/^ HTTP\// { s = $2 }
|
||||
/ error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) }
|
||||
tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" }
|
||||
END { print (s ? s : "none"), (x ? x : "-") }'
|
||||
}
|
||||
# host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
||||
NR == 1 { s = $2 }
|
||||
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
||||
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
||||
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
||||
}
|
||||
check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; }
|
||||
expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; }
|
||||
expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; }
|
||||
release() { killall wget >/dev/null 2>&1; sleep 2; }
|
||||
PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F"
|
||||
INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F"
|
||||
# 휴대폰 앱 받기 — 끝의 / · %66(f) 꼴도. 정보 경로는 받기 location 밖이다
|
||||
APK="/api/app-releases/android/latest/file /api/App-Releases/Android/Latest/FILE /api/APP-RELEASES/android/latest/File /api//app-releases/android/latest/file /api/app-releases/android%2Flatest/file /api/app-releases/android/latest/file/ /api/app-releases/android/latest/%66ile"
|
||||
INFO="/api/app-releases/android/latest /api/App-Releases/Android/Latest /api/app-releases/android/latest/"
|
||||
|
||||
echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도"
|
||||
for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done
|
||||
for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done
|
||||
echo "== 휴대폰 앱 받기도 받기 location 을 탄다 · 공개 호스트는 닫는다 · 정보 경로는 /api 그대로"
|
||||
for p in $APK; do expect "200 xab" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
|
||||
for p in $INFO; do expect "200 -" portal.ci.test "$p"; expect "404 -" files.ci.test "$p"; done
|
||||
expect "200 -" files.ci.test "/api/Public-Links/$T"
|
||||
expect "200 -" portal.ci.test "/api/Messages/M1"
|
||||
expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1"
|
||||
expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1"
|
||||
expect "200 xab" portal.ci.test "/api/App-Releases/android/latest/file?pad=1"
|
||||
echo "== nginx 가 끝내는 /api 응답은 JSON"
|
||||
expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me
|
||||
expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000"
|
||||
expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F"
|
||||
expect_raw "404 application/json RES_001" files.ci.test GET /api/app-releases/android/latest/file
|
||||
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30(휴대폰 앱 받기와 함께). 붙잡아 둔 뒤 변형으로 하나 더"
|
||||
hold files.ci.test "/api/public-links/$T/files/$F" 10
|
||||
for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done
|
||||
expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F"
|
||||
expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F"
|
||||
expect "200 xab" portal.ci.test "/api/app-releases/android/latest/file"
|
||||
release
|
||||
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
|
||||
for p in $INAPP $APK; do expect "429 -" portal.ci.test "$p"; done
|
||||
expect_raw "429 application/json REQ_001" portal.ci.test GET "/api/App-Releases/android/latest/FILE"
|
||||
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
||||
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
||||
release
|
||||
hold portal.ci.test "/api/app-releases/android/latest/file" 30
|
||||
for p in $APK $INAPP; do expect "429 -" portal.ci.test "$p"; done
|
||||
expect "200 -" portal.ci.test "/api/app-releases/android/latest"
|
||||
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
|
||||
release
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
{ printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile"
|
||||
RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \
|
||||
&& pad=$(head -c 3000 /dev/zero | tr '\0' a) \
|
||||
&& printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf
|
||||
COPY --chmod=755 paths.sh /usr/local/bin/paths.sh
|
||||
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 -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; }
|
||||
|
||||
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
|
||||
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
|
||||
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
|
||||
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
|
||||
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
|
||||
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
|
||||
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
|
||||
- name: 닫는 경로 시험
|
||||
run: |
|
||||
name="ci-nginx-paths-${{ github.run_id }}"
|
||||
docker exec -i "$name" sh -s <<'EOF'
|
||||
fails=0
|
||||
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
|
||||
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
|
||||
raw() {
|
||||
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; [ -n "$WAIT" ] && sleep 1; } \
|
||||
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
|
||||
NR == 1 { s = $2 }
|
||||
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
|
||||
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
|
||||
END { print s, (t ? t : "-"), (c ? c : "-") }'
|
||||
}
|
||||
# 찍을 때 0x80 이상 바이트는 ~ 로
|
||||
check() {
|
||||
label=$(printf '%s' "$2" | tr '\200-\377' '~')
|
||||
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
|
||||
}
|
||||
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
|
||||
BS=$(printf '\')
|
||||
NB=$(printf '\240')
|
||||
E=mail-engine-events
|
||||
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
|
||||
F=11111111-2222-4333-8444-555555555555
|
||||
for host in portal.ci.test files.ci.test; do
|
||||
echo "== $host — /api/internal 은 404"
|
||||
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
|
||||
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
|
||||
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
|
||||
expect "404 application/json RES_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
|
||||
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
|
||||
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
|
||||
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
|
||||
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
|
||||
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
|
||||
expect "400 application/json VAL_001" "$host" POST "$path"
|
||||
done
|
||||
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
|
||||
done
|
||||
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
|
||||
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
|
||||
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
|
||||
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
|
||||
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
|
||||
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
|
||||
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
|
||||
echo "fails=$fails"
|
||||
[ "$fails" -eq 0 ]
|
||||
EOF
|
||||
|
||||
# 메일 프로그램 자동 설정(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 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
|
||||
# AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)")
|
||||
- name: ClamAV 설정 검사
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
# clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서
|
||||
# compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭)
|
||||
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }')
|
||||
[ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; }
|
||||
tag="ci-clamav-config:${{ github.run_id }}"
|
||||
printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav
|
||||
conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1)
|
||||
printf '%s\n' "$conf"
|
||||
if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then
|
||||
echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1
|
||||
fi
|
||||
for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do
|
||||
printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; }
|
||||
done
|
||||
# -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은
|
||||
# -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게)
|
||||
clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on')
|
||||
for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do
|
||||
printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; }
|
||||
done
|
||||
|
||||
# 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
|
||||
- name: unbound 설정 검사
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | grep unbound)
|
||||
tag="ci-unbound-config:${{ github.run_id }}"
|
||||
printf 'FROM %s\nCOPY unbound.conf /etc/unbound/unbound.conf\n' "$image" | docker build -t "$tag" -f- mail/resolver
|
||||
docker run --rm --user 101:102 --entrypoint unbound-checkconf "$tag"
|
||||
|
||||
- name: 정리
|
||||
if: always()
|
||||
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
run: |
|
||||
docker rm -f "ci-nginx-paths-${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true
|
||||
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
|
||||
|
||||
error-codes-sync:
|
||||
name: 에러 코드 SSOT 동기화 검사
|
||||
|
||||
@@ -21,8 +21,13 @@
|
||||
/redis-data/
|
||||
# CI 러너 등록 정보
|
||||
ci/runner-data/
|
||||
# 운영 서버의 백업 · 감시 설정(13단계 — S3 키 · Healthchecks 주소 · restic 비밀번호). 서버의 /etc/arasystem/ 에 두지만 저장소 안에 잘못 만들어도 올라가지 않게
|
||||
/ops/*.env
|
||||
/ops/restic-password
|
||||
# 업로드 파일 (로컬 저장) — 코드가 아니다
|
||||
backend/uploads/
|
||||
# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다
|
||||
backend/files/
|
||||
|
||||
# ==========================================
|
||||
# 의존성/빌드 산출물
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 프로젝트 개요
|
||||
|
||||
이 저장소는 **풀스택 모노레포 기본 템플릿**이다. 누구나 클론하여 바로 개발을 시작할 수 있도록 공통 규칙과 언어별 규칙을 정의한다.
|
||||
이 저장소는 **ARA SYSTEM** — 사내 그룹웨어(첫 모듈은 메일: 웹메일 · 휴대폰 앱)다. 백엔드 · 웹 · 앱과 메일 엔진 · 운영 설정을 한 저장소에서 다루고, 여기에 공통 규칙과 스택별 규칙을 둔다.
|
||||
프로젝트 소개 · 문서 지도는 [`README.md`](README.md), 작업 차례는 [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 개발 규칙 (스킬) |
|
||||
| ----------- | -------------- | ------------------------ | ----------------------------------------- |
|
||||
|
||||
@@ -1,109 +1,97 @@
|
||||
# 풀스택 모노레포 기본 템플릿
|
||||
# ARA SYSTEM
|
||||
|
||||
NestJS(백엔드) · Vue 3(웹) · Flutter(모바일)를 하나의 저장소에서 운영하는 풀스택 템플릿입니다.
|
||||
세 클라이언트는 **동일한 표준 응답 포맷**과 **공통 에러 코드 맵**을 공유하며, Docker Compose로 한 번에 구동됩니다.
|
||||
사내 그룹웨어다. 첫 모듈은 **메일** — 웹메일(포털)과 휴대폰 앱(안드로이드)이다.
|
||||
메일 엔진(Stalwart)이 메일을 주고받고, 우리 서버(NestJS)가 엔진을 감싸 웹과 앱에 같은 API 를 준다. 화면은 직접 만든다(Vue 3 · Flutter).
|
||||
`@arasystem.it.kr` 메일은 2026-11-05 옛 서버(mailcow)에서 옮겨 와 전 직원이 쓰고 있다.
|
||||
|
||||
## 지금
|
||||
|
||||
- 운영: 메일 1~13단계. 메일 14단계(자체 검색 색인 · 같은 대화)와 앱 첫 판 · 휴대폰 앱 받기는 main 에 합쳤고 서버 배포를 기다린다.
|
||||
- 개발 중: 메일 15단계(메일 · 메일함 다루기). 다음은 16단계(개인 설정 — 주소록 가져오기 · 여러 서명 등).
|
||||
- 단계별 내용 · 다음 차례 · 미뤄 둔 후보는 [`docs/roadmap.md`](./docs/roadmap.md).
|
||||
|
||||
## 구성
|
||||
|
||||
| 디렉터리 | 스택 | 역할 | 포트(기본) |
|
||||
| ----------- | -------------- | ------------------------ | ---------- |
|
||||
| `backend/` | NestJS + TypeORM | REST API 서버 | 3000 |
|
||||
| `frontend/` | Vue 3 + Vite | 웹 프론트엔드 | 5173 |
|
||||
| `flutter/` | Flutter + Riverpod | 모바일 앱 | - |
|
||||
| `db` | PostgreSQL | 데이터베이스 | 5432 |
|
||||
| `redis` | Redis | 캐시 / 세션 (**기본 비활성**) | 6379 |
|
||||
| `mail` | Stalwart (JMAP) | 사내 메일 엔진 — 직원 로그인 = 메일 계정 (profile `mail`, [`mail/README.md`](./mail/README.md)) | 운영 25·465·993·443 / 개발 관리 8180 |
|
||||
| `mailpit` | Mailpit | **개발 전용** — 사내 도메인 밖으로 보낸 메일 수집 | 8025 |
|
||||
| 이름 | 스택 | 하는 일 | 포트(개발) |
|
||||
| --- | --- | --- | --- |
|
||||
| `backend/` | NestJS · TypeORM | 웹 · 앱이 함께 쓰는 REST API — 메일 엔진 중계, 로그인 세션, 검색 색인, 앱 알림, 대용량 첨부 | 3000 |
|
||||
| `frontend/` | Vue 3 · Vite | 웹메일 화면. 운영은 nginx 가 화면과 같은 주소의 `/api` 를 백엔드로 넘긴다 | 5173 |
|
||||
| `flutter/` | Flutter · Riverpod | 휴대폰 앱(안드로이드 — 스토어 없이 사내 배포) | - |
|
||||
| `db` | PostgreSQL 15 | 로그인 세션 · 설정 · 검색 색인 · 대용량 첨부 기록 등 | 5432 |
|
||||
| `mail` | Stalwart | 메일 엔진(JMAP · IMAP · SMTP) — 직원 로그인 = 메일 계정 | 관리 8180(운영은 25 · 465 · 993 · 443) |
|
||||
| `mail-resolver` | unbound | 메일 엔진 전용 DNS(스팸 차단 목록 · DNSSEC) | - |
|
||||
| `clamav` | ClamAV | 대용량 첨부 악성 검사(운영은 늘, 개발은 profile `clamav`) | - |
|
||||
| `mailpit` | Mailpit | 개발 전용 — 바깥 주소로 보낸 메일을 모은다 | 8025 |
|
||||
| `redis` | Redis | 기본 꺼짐 — 서버를 여러 대로 늘릴 때의 자리 | 6379 |
|
||||
| `ops/` | 셸 · systemd | 운영 서버의 백업(restic) · 15분 점검 · 부팅 때 되살리기 | - |
|
||||
| `ci/` | Gitea Actions 러너 | PR 마다 lint · 시험 · 빌드 · 인프라 검사 | - |
|
||||
|
||||
## 문서 지도
|
||||
|
||||
| 알고 싶은 것 | 문서 |
|
||||
| --- | --- |
|
||||
| 서버 · 웹 · 앱이 함께 지키는 API 약속 | [`docs/mail/api-contract.md`](./docs/mail/api-contract.md) |
|
||||
| 웹 화면 디자인 · 화면 문구 규칙 · 단계별 화면 | [`docs/design/webmail/README.md`](./docs/design/webmail/README.md) — 디자인 원본은 같은 폴더의 `*.dc.html`(캔버스 보드) |
|
||||
| 앱 화면 | [`docs/design/app/README.md`](./docs/design/app/README.md) |
|
||||
| 메일 엔진 설정 · 운영 배포 · 백업 · 감시 · 전환 | [`mail/README.md`](./mail/README.md) |
|
||||
| 앱 개발 실행 · 사내 배포(서명 키 · APK 올리기) | [`flutter/README.md`](./flutter/README.md) |
|
||||
| CI 러너 | [`ci/README.md`](./ci/README.md) |
|
||||
| 개발 규칙(공통 · 스택별 · 역할) | [`CLAUDE.md`](./CLAUDE.md) · 아래 "개발 규칙" |
|
||||
| 로드맵 | [`docs/roadmap.md`](./docs/roadmap.md) |
|
||||
| 로고 · 색 | [`docs/brand/`](./docs/brand/) |
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
- Docker / Docker Compose — backend·frontend·db·redis 구동 (컨테이너 안 런타임은 Node 24 LTS — `node:24.22.1-alpine3.24` 고정, 갱신 규칙은 docker 스킬 "Node 버전")
|
||||
- Node.js **24 LTS 권장** — 호스트에서 프론트엔드 dev 서버·메일 setup 스크립트를 실행할 때 필요하다.
|
||||
프론트 `engines` 는 `^20.19.0 || >=22.12.0` 이라 20.19 이상이면 돌아가지만, 이미지·CI(24)와 맞추는 것을 권장한다
|
||||
(메일 setup 스크립트는 `--env-file` 때문에 20.6 이상)
|
||||
- Flutter SDK `^3.11` — 모바일 앱 실행용 (compose 대상 아님)
|
||||
- Docker / Docker Compose — 백엔드 · DB · 메일 엔진(컨테이너 안 Node 는 24 LTS 로 고정 — docker 스킬 "Node 버전")
|
||||
- Node.js 24 LTS — 호스트에서 웹 개발 서버 · 메일 setup 스크립트를 돌릴 때
|
||||
- Flutter 3.41.9(Dart 3.11.5) — 앱(compose 대상이 아니다 — [`flutter/README.md`](./flutter/README.md))
|
||||
|
||||
## 빠른 시작 (Docker)
|
||||
## 빠른 시작 (개발)
|
||||
|
||||
```bash
|
||||
# 1) 환경변수 파일 생성 (예제 복사)
|
||||
# 실제 설정값은 루트 .env.development 한 곳에서 관리한다 (SSOT).
|
||||
# backend/frontend 는 docker-compose 가 값을 주입하므로 예제만 복사하면 된다.
|
||||
cp .env.development.example .env.development # ← 여기에만 값 입력
|
||||
cp backend/.env.development.example backend/.env.development # (존재 필요: JWT 등 고유 시크릿)
|
||||
# frontend 는 compose 가 빌드 시 값을 주입하므로 자체 env 복사 불필요
|
||||
# 1) 환경변수 파일 — 값은 루트 .env.development 한 곳에 쓴다(SSOT)
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development # 있어야 한다(JWT 등 백엔드 고유 비밀)
|
||||
|
||||
# 2) 백엔드 스택 기동 (backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail + mailpit 포함)
|
||||
# 2) 백엔드 스택 — backend + db, 기본 COMPOSE_PROFILES=mail 이면 mail · mail-resolver · mailpit 까지
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2-1) 메일 엔진 초기화 — 처음 한 번 (여러 번 실행해도 안전, mail/README.md)
|
||||
# 3) 메일 엔진 초기화 — 처음 한 번(여러 번 돌려도 안전하다). 개발 시험 계정도 여기서 만든다(mail/README.md)
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
# 3) 의존성 변경 시에만 --build
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d --build
|
||||
|
||||
# 4) 프론트엔드는 호스트에서 직접 띄운다 (컨테이너보다 수십 배 빠르다)
|
||||
# 4) 웹은 호스트에서 띄운다 — /api 는 vite 프록시가 백엔드 컨테이너로 넘긴다
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
기동 후:
|
||||
- 웹 <http://localhost:5173> · API <http://localhost:3000/api> · Swagger <http://localhost:3000/api-docs>
|
||||
- 의존성을 바꿨을 때만 `up -d --build`(백엔드는 `--renew-anon-volumes` 도 — docker 스킬).
|
||||
- 백엔드를 처음 띄우면 watch 빌드에 수십 분이 걸릴 수 있다(Windows 바인드 마운트). 브랜치를 바꿨다고 다시 시작하지 않는다.
|
||||
- 웹을 컨테이너로 띄워야 하면 `--profile frontend`(느리다 — 호스트 vite 와 함께 띄우지 않는다). 운영(`docker-compose.yml` 단독)은 nginx 정적 호스팅이다.
|
||||
- 포트는 루트 `.env.{env}` 를 따른다(`FRONTEND_PORT` · `BACKEND_PORT` — `vite.config.ts` 가 직접 읽는다). 포트가 이미 쓰이면 다른 포트로 옮기지 않고 실패한다.
|
||||
- Redis 는 뜨지 않는다. 필요해지면 `COMPOSE_PROFILES` 에 `redis` 를 더한다.
|
||||
|
||||
- 웹: <http://localhost:5173>
|
||||
- API: <http://localhost:3000/api>
|
||||
- Swagger 문서: <http://localhost:3000/api-docs>
|
||||
전체 실행 · 배포 명령은 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md).
|
||||
|
||||
> **개발 환경에서 프론트엔드는 컨테이너로 뜨지 않습니다.**
|
||||
> vite 를 컨테이너에서 돌리면 소스가 Windows 바인드 마운트 너머에 있어 첫 서빙까지 수 분이 걸립니다.
|
||||
> 호스트에서 `npm run dev` 로 띄우면 1~2초이고, `/api` 요청은 vite 프록시가 백엔드 컨테이너로 넘깁니다.
|
||||
> 컨테이너로 띄워야 한다면 `--profile frontend` 를 붙이세요 (아래 "프론트엔드 실행 방식" 참조).
|
||||
> 운영(`docker-compose.yml` 단독)은 그대로 nginx 정적 호스팅으로 동작합니다.
|
||||
|
||||
> Redis 는 기본적으로 뜨지 않습니다. 필요해지면 루트 `.env.{env}` 에 `COMPOSE_PROFILES=redis` 를 설정하세요.
|
||||
|
||||
### 프론트엔드 실행 방식
|
||||
|
||||
| 방식 | 명령 | 첫 서빙 | 용도 |
|
||||
| --- | --- | --- | --- |
|
||||
| **호스트 vite (개발 기본)** | `cd frontend && npm run dev` | 1~2초 (최초 의존성 사전번들 10초 내외) | 일상 개발 |
|
||||
| 컨테이너 vite (선택) | `docker compose ... --profile frontend up -d` | 수 분 (Windows 바인드 마운트) | 호스트에 Node 를 둘 수 없을 때 |
|
||||
| nginx 정적 (운영) | `docker compose --env-file .env.production up -d --build` | - | 배포 |
|
||||
|
||||
> 컨테이너 방식을 상시 사용하려면 루트 `.env.development` 에 `COMPOSE_PROFILES=frontend` 를 넣습니다.
|
||||
> (redis 와 함께 켤 때는 `COMPOSE_PROFILES=frontend,redis`)
|
||||
> 호스트 vite 와 컨테이너 vite 는 같은 호스트 포트를 쓰므로 **동시에 띄우지 마세요.**
|
||||
|
||||
> **포트는 루트 `.env.{env}` 를 따릅니다.** 호스트에서 띄우는 vite 도 `FRONTEND_PORT` 로 열리고,
|
||||
> `/api` 프록시 대상은 `BACKEND_PORT` 에서 나옵니다 — `vite.config.ts` 가 루트 env 를 직접 읽습니다.
|
||||
> 포트가 이미 사용 중이면 조용히 다른 포트로 옮기지 않고 **실패**합니다
|
||||
> (`CORS_ORIGIN` 과 compose 포트 매핑이 그 값에 묶여 있어, 말없이 옮겨가면 원인 모르게 어긋납니다).
|
||||
|
||||
전체 실행/배포 명령어는 [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) 참조.
|
||||
|
||||
## 모바일 앱 실행 (Flutter)
|
||||
|
||||
Flutter 는 Docker Compose 대상이 아니므로 로컬에서 직접 실행합니다.
|
||||
## 앱 실행 (Flutter)
|
||||
|
||||
```bash
|
||||
cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> `flutter/.env` 의 `API_URL` 을 백엔드 주소로 설정합니다. (Android 에뮬레이터는 `http://10.0.2.2:3000/api`)
|
||||
`flutter/.env` 의 `API_URL` 을 백엔드 주소로 둔다(Android 에뮬레이터는 `http://10.0.2.2:3000/api`). 실기기 · 새 메일 알림 · 사내 배포는 [`flutter/README.md`](./flutter/README.md).
|
||||
|
||||
## 환경변수
|
||||
|
||||
`.env*` 는 커밋하지 않습니다. 각 디렉터리의 `*.example` 파일을 복사해 사용하세요.
|
||||
`.env*` 는 커밋하지 않는다. 각 자리의 `*.example` 을 복사해 쓴다. **루트 `.env.{env}` 가 단일 진실 공급원(SSOT)**이다 — backend · frontend 는 compose 가 루트 값을 넣어 준다
|
||||
(구조는 [`.claude/rules/env-structure.md`](./.claude/rules/env-structure.md)).
|
||||
|
||||
**루트 `.env.{env}` 가 단일 진실 공급원(SSOT)** 입니다. backend·frontend 는 docker-compose 가
|
||||
루트 값을 주입하므로 자체 env 에 값을 중복 작성하지 않습니다. (자세한 위임 구조는 [`CLAUDE.md`](./CLAUDE.md) 의 "환경변수 구조" 참조)
|
||||
| 파일 | 용도 | 값을 쓰는 곳 |
|
||||
| --- | --- | --- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT(compose 가 넣어 준다) | 여기 |
|
||||
| `backend/.env.{env}.example` | 백엔드 고유 비밀(JWT · 앱 알림 키 등)만 | 최소(파일은 있어야 한다) |
|
||||
| `flutter/.env.example` | 앱 — `API_URL`(compose 대상이 아니다) | 따로 |
|
||||
|
||||
| 파일 | 용도 | 값 작성 위치 |
|
||||
| ----------------------------- | ---------------------------------------- | --------------------- |
|
||||
| `.env.{env}.example` | 루트 — 모든 설정의 SSOT (compose 주입) | ✅ 여기에 작성 |
|
||||
| `backend/.env.{env}.example` | 백엔드 — 고유 시크릿(JWT 등)만 | 최소 (존재 필수) |
|
||||
| `frontend/.env.{env}.example` | 프론트 — API 주소는 같은 출처의 `/api` 고정(빌드 arg) | 작성 불필요 |
|
||||
| `flutter/.env.example` | 모바일 — `API_URL` (compose 비대상) | ✅ 독립 관리 |
|
||||
|
||||
`{env}` = `development` | `production`
|
||||
`{env}` = `development` | `production`. 운영 서버의 백업 · 점검 설정은 `/etc/arasystem/`(`ops/ops.env.example` — mail/README "백업").
|
||||
|
||||
## 표준 응답 포맷
|
||||
|
||||
@@ -114,97 +102,74 @@ cd flutter && cp .env.example .env && flutter pub get && flutter run
|
||||
// 실패 — 사용자에게 보이는 모든 실패는 에러 코드를 가진다
|
||||
{ "success": false, "error": { "code": "VAL_001", "message": "...", "traceId": "3f1c0a2e-..." } }
|
||||
|
||||
// 목록 — data 안의 형태가 고정되어 있다
|
||||
// 목록 — data 안의 꼴이 정해져 있다
|
||||
{ "success": true, "data": { "items": [], "pageInfo": { "hasNext": false, "nextCursor": null, "total": 0 } } }
|
||||
```
|
||||
|
||||
`traceId` 는 요청마다 발급되는 추적 ID(`x-request-id` 헤더와 동일)로, 서버 로그에 같은 값이 남습니다.
|
||||
사용자 문의가 들어오면 이 값으로 로그를 검색하면 됩니다.
|
||||
`traceId` 는 요청마다 붙는 추적 ID(`x-request-id` 헤더와 같다)라 서버 로그에 같은 값이 남는다 — 문의가 오면 이 값으로 로그를 찾는다.
|
||||
`/api/health` 는 예외다(모니터링 도구가 읽는 원본 꼴).
|
||||
|
||||
> `/api/health` 는 예외입니다. 모니터링 도구가 읽는 엔드포인트라 래퍼 없이 원본 형식을 반환합니다.
|
||||
|
||||
### 에러 코드는 한 곳에서 관리됩니다
|
||||
### 에러 코드는 한 곳에서
|
||||
|
||||
```
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 수정
|
||||
backend/src/common/errors/error-codes.ts ← 여기만 고친다
|
||||
│ cd backend && npm run sync:error-codes
|
||||
├─▶ frontend/src/shared/constants/error-codes.generated.ts (자동 생성)
|
||||
└─▶ flutter/lib/core/api/error_lexicon.g.dart (자동 생성)
|
||||
```
|
||||
|
||||
생성 파일은 직접 수정하지 마세요. CI 가 동기화 여부를 검사합니다.
|
||||
자세한 규칙은 [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md) 참조.
|
||||
생성 파일은 직접 고치지 않는다. CI 가 맞는지 본다 — [`.claude/rules/error-codes.md`](./.claude/rules/error-codes.md).
|
||||
|
||||
## 로컬 개발 (Docker 없이)
|
||||
|
||||
DB 는 컨테이너로 띄우고 앱만 호스트에서 실행하는 방식이 가장 간단합니다.
|
||||
## 커밋 전 검사
|
||||
|
||||
```bash
|
||||
# 1) DB 만 컨테이너로 기동 (127.0.0.1:5432 로 노출된다)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d db
|
||||
|
||||
# 2) backend — DB_HOST 기본값이 localhost 라 그대로 붙는다
|
||||
cd backend && npm ci && npm run start:dev
|
||||
|
||||
# 3) frontend — vite dev 서버가 /api 를 localhost:3000 으로 프록시한다 (개발 기본 방식)
|
||||
cd frontend && npm ci && npm run dev
|
||||
|
||||
# 4) flutter
|
||||
cd flutter && flutter pub get && flutter run
|
||||
```
|
||||
|
||||
> 프론트엔드의 API 요청은 `/api` 상대경로로 나가고 vite dev 프록시가 백엔드로 넘깁니다.
|
||||
> 백엔드를 다른 주소에서 띄웠다면 `VITE_DEV_API_PROXY` 로 대상을 바꾸세요.
|
||||
|
||||
### 커밋 전 검사
|
||||
|
||||
```bash
|
||||
cd backend && npm run lint && npm run test && npm run build
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit
|
||||
# backend — 컨테이너 안에서(의존성 · lock 이 Linux 기준이라 호스트에서 npm install 하지 않는다)
|
||||
docker exec arasystem-backend npm run lint
|
||||
docker exec arasystem-backend npm run test # 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진이 떠 있어야 한다
|
||||
# frontend · flutter — 호스트에서
|
||||
cd frontend && npm run lint && npm run type-check && npm run test:unit # 시간대가 걸린 화면을 고쳤으면 TZ=UTC 로도
|
||||
cd flutter && flutter analyze && flutter test
|
||||
```
|
||||
|
||||
lint 는 `--max-warnings 0` 이라 경고 하나만 있어도 실패합니다. 자동 수정은 `npm run lint:fix`.
|
||||
같은 검사를 CI([`.github/workflows/ci.yml`](./.github/workflows/ci.yml))가 PR 마다 수행합니다.
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). lint 는 `--max-warnings 0` 이라 경고 하나도 실패다. 같은 검사를 CI(`.github/workflows/ci.yml` · `ci-flutter.yml`)가 PR 마다 한다.
|
||||
|
||||
> ⚠️ **의존성을 추가·제거할 때는 컨테이너 안에서** 실행하세요.
|
||||
> Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 잘려 Docker 빌드가 깨집니다.
|
||||
> ```bash
|
||||
> docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>
|
||||
> ```
|
||||
> ⚠️ **백엔드 의존성은 컨테이너 안에서만 바꾼다** — Windows 에서 `npm install` 하면 Linux 전용 패키지가 lock 에서 빠져 Docker 빌드가 깨진다.
|
||||
> `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development exec backend npm install <패키지>`
|
||||
|
||||
## 개발 규칙
|
||||
|
||||
| 영역 | 문서 |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| 공통/보안 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답·에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 | [`logging.md`](./.claude/rules/logging.md) |
|
||||
| 체크리스트 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
| 영역 | 문서 |
|
||||
| --- | --- |
|
||||
| 입구 · 공통 | [`CLAUDE.md`](./CLAUDE.md) · [`.claude/rules/`](./.claude/rules/) |
|
||||
| 응답 · 에러 | [`response-format.md`](./.claude/rules/response-format.md) · [`error-codes.md`](./.claude/rules/error-codes.md) |
|
||||
| API 설계 | [`api-design.md`](./.claude/rules/api-design.md) |
|
||||
| 로깅 · 보안 | [`logging.md`](./.claude/rules/logging.md) · [`security.md`](./.claude/rules/security.md) |
|
||||
| 점검 목록 | [`checklist.md`](./.claude/rules/checklist.md) |
|
||||
| NestJS | [`.claude/skills/nestjs/SKILL.md`](./.claude/skills/nestjs/SKILL.md) |
|
||||
| Vue 3 | [`.claude/skills/vue3/SKILL.md`](./.claude/skills/vue3/SKILL.md) |
|
||||
| Flutter | [`.claude/skills/flutter/SKILL.md`](./.claude/skills/flutter/SKILL.md) |
|
||||
| Docker · 운영 | [`.claude/skills/docker/SKILL.md`](./.claude/skills/docker/SKILL.md) |
|
||||
| 역할 분담 | [`.claude/agents/README.md`](./.claude/agents/README.md) |
|
||||
|
||||
새 화면은 캔버스 보드로 먼저 확인받은 뒤 만든다(디자인 README). 화면 · 알림 글은 디자인 README 「화면 문구 — 사용자에게 말하듯」을 따른다.
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/ # NestJS API (Controller / Service / DTO / 공통 필터·인터셉터)
|
||||
├── frontend/ # Vue 3 (pages / components / composables / stores / router)
|
||||
├── flutter/ # Flutter (core / features / shared)
|
||||
├── docker-compose.yml # 베이스 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드 (핫리로드, 포트 노출)
|
||||
├── CLAUDE.md # 프로젝트 개요 + 공통 규칙 @import
|
||||
├── .github/
|
||||
│ ├── workflows/ci.yml # lint·test·build + 에러 코드 동기화 + 인프라(compose·Dockerfile·nginx) 검사
|
||||
│ └── pull_request_template.md
|
||||
├── ci/ # CI 러너 (Gitea Actions) — 앱 스택과 분리
|
||||
└── .claude/
|
||||
├── settings.json # Claude Code 권한·환경 설정
|
||||
├── rules/ # 공통 규칙 (코딩·응답 포맷·에러 코드·API 설계·로깅·보안·환경변수·테스트)
|
||||
├── skills/ # 도메인별 개발 규칙 (nestjs·vue3·flutter·docker)
|
||||
└── agents/ # 역할별 규칙 (개발 3종·테스터·리뷰어·인프라·사용자/관리자 관점)
|
||||
├── backend/ # NestJS API — modules/(auth · mail · contacts · file-storage · push · ops 등) · common · shared
|
||||
├── frontend/ # Vue 3 웹메일 — pages · components · composables · stores · router, 운영 nginx 설정
|
||||
├── flutter/ # 휴대폰 앱 — core · features · shared
|
||||
├── mail/ # 메일 엔진 setup 스크립트 · DNS 리졸버 · ClamAV 설정 · 운영 문서(README)
|
||||
├── ops/ # 운영 서버의 백업 · 점검 · 부팅 되살리기(systemd)
|
||||
├── docs/ # API 계약 · 화면 디자인(캔버스 보드) · 브랜드 · 로드맵
|
||||
├── ci/ # CI 러너(Gitea Actions) — 앱 스택과 따로 띄운다
|
||||
├── data/ # 개발용 바인드 마운트 자리(내용은 커밋하지 않는다)
|
||||
├── docker-compose.yml # 운영 · 개발 공통 정의
|
||||
├── docker-compose.dev.yml # 개발 오버라이드(핫리로드 · 127.0.0.1 포트)
|
||||
├── docker-compose.remote-proxy.yml # 리버스 프록시가 다른 서버에 있을 때(운영 선택)
|
||||
├── CLAUDE.md # 개발 규칙의 입구(.claude/ 의 규칙을 불러온다)
|
||||
├── .github/ # CI 워크플로(ci.yml · ci-flutter.yml) · PR 양식
|
||||
└── .claude/ # rules(공통 규칙) · skills(스택별 규칙) · agents(역할) · 설정
|
||||
```
|
||||
|
||||
@@ -12,6 +12,8 @@ node_modules
|
||||
dist
|
||||
coverage
|
||||
uploads
|
||||
# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게)
|
||||
files
|
||||
logs
|
||||
*.log
|
||||
.env
|
||||
|
||||
@@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다.
|
||||
# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다.
|
||||
# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게).
|
||||
# 만드는 법: base64 -w0 key.json
|
||||
# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
|
||||
# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로.
|
||||
FCM_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
@@ -20,3 +20,11 @@ JWT_SECRET=change-me-generate-a-long-random-string
|
||||
# 세션 절대 만료(로그인 후 30일)는 코드 상수다.
|
||||
JWT_ACCESS_EXPIRES_IN=1h
|
||||
JWT_REFRESH_EXPIRES_IN=14d
|
||||
|
||||
# 앱 새 메일 알림 (FCM — 앱 1단계, 선택) — Firebase 서비스 계정 키 JSON 파일을 통째로 base64 한 줄로 넣는다.
|
||||
# 비우면 앱 알림만 끈다(기동은 한다 — app-config 의 pushAvailable: false). 실제 키 값은 이 예시 파일에 적지 않고 커밋하지 않는다.
|
||||
# 알림 전용 서비스 계정(역할 "Firebase Cloud Messaging API Admin" 하나)의 키를 개발 · 운영 따로 만든다(하나만 막을 수 있게).
|
||||
# 만드는 법: base64 -w0 key.json
|
||||
# (PowerShell) [Convert]::ToBase64String([IO.File]::ReadAllBytes("key.json"))
|
||||
# 모양이 틀리면 기동하지 않는다 (src/shared/config/env.validation.ts — 어느 칸이 틀렸는지만 알린다). 키가 새면 Google Cloud 콘솔에서 지우고 새 키로.
|
||||
FCM_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
+7
-2
@@ -25,6 +25,9 @@ RUN npm ci
|
||||
|
||||
COPY --chown=node:node . .
|
||||
RUN npm run build
|
||||
# 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — dev 는 이 단계에 named volume <PROJECT_NAME>_files 를 붙인다.
|
||||
# 새 볼륨은 이 디렉터리의 소유자(node)를 물려받는다 (없으면 root 소유로 생겨 node 가 쓰지 못한다). 빌드 컨텍스트의 files/ 는 .dockerignore 가 뺀다
|
||||
RUN mkdir -p /app/files
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine3.24 AS production
|
||||
ARG NPM_VERSION
|
||||
@@ -40,11 +43,13 @@ COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
# 업데이트 내역 글(글 파일 하나가 글 하나 — 계약 7절) — 서버가 기동 · 요청 때 /app/release-notes 를 읽기만 한다(modules/release-notes — 쓰지 않는다)
|
||||
COPY --from=builder /app/release-notes ./release-notes
|
||||
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다.
|
||||
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다)
|
||||
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||
RUN mkdir -p /app/uploads /app/files && chown node:node /app/uploads /app/files
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
+22
-84
@@ -1,98 +1,36 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# backend — ARA SYSTEM API (NestJS)
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
웹 · 앱이 함께 쓰는 REST API 다(`/api`). 메일 엔진(Stalwart)을 JMAP 으로 감싸고, 로그인 세션 · 설정 · 검색 색인 · 앱 알림 · 대용량 첨부 · 운영 도구를 맡는다.
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
- 모듈 지도 · 개발 규칙: [`.claude/skills/nestjs/SKILL.md`](../.claude/skills/nestjs/SKILL.md)
|
||||
- API 약속(웹 · 앱과 함께): [`docs/mail/api-contract.md`](../docs/mail/api-contract.md)
|
||||
- 공통 규칙(응답 꼴 · 에러 코드 · 로그 · 보안): [`CLAUDE.md`](../CLAUDE.md)
|
||||
|
||||
## Description
|
||||
## 실행 — 컨테이너로
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
백엔드는 늘 컨테이너로 띄운다. 의존성 · lock 이 Linux 기준이라 **호스트에서 `npm install` 하지 않는다**(바꿀 때도 컨테이너 안에서 — nestjs 스킬 "의존성 추가/제거").
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
# 저장소 루트에서 — 개발 스택(backend + db + 메일 엔진)
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development logs -f backend
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
- 처음 띄우면 마이그레이션을 한 번 돌리고 watch 빌드를 시작한다(Windows 바인드 마운트라 수십 분 걸릴 수 있다). 코드를 고치면 저절로 다시 뜬다.
|
||||
- Swagger: <http://localhost:3000/api-docs>
|
||||
|
||||
## 시험 · 검사
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
docker exec arasystem-backend npm run lint # 경고 0
|
||||
docker exec arasystem-backend npm run test # 단위 — 메모리가 빠듯하면 -- --maxWorkers=2
|
||||
docker exec arasystem-backend npx jest --config ./test/jest-e2e.json # e2e — DB · 메일 엔진 필요
|
||||
```
|
||||
|
||||
## Run tests
|
||||
컨테이너 이름은 `<PROJECT_NAME>-backend`(개발 기본 `arasystem`). Git Bash 에서 컨테이너 안 경로를 줄 때는 `MSYS_NO_PATHCONV=1` 을 붙인다.
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
## 자주 하는 일
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
- **에러 코드**: `src/common/errors/error-codes.ts` 만 고치고 호스트에서 `cd backend && npm run sync:error-codes`(웹 · 앱 사전이 함께 바뀐다).
|
||||
- **DB 스키마**: 엔티티를 고친 뒤 `docker exec arasystem-backend npm run migration:generate -- src/migrations/<이름>` → 만든 SQL 을 눈으로 확인 → 엔티티와 함께 커밋(nestjs 스킬 "DB 마이그레이션").
|
||||
- **운영 도구**(운영 서버 안 명령 — 입사 · 비밀번호 재설정 · 휴대폰 앱 올리기 등): `sudo docker exec -it <PROJECT_NAME>-backend node dist/cli/ops.js` — 계약 6절 · mail/README.
|
||||
|
||||
Generated
+26
-26
@@ -33,7 +33,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -5764,18 +5764,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^2.0.0",
|
||||
"content-type": "^2.1.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.15.2",
|
||||
"qs": "^6.16.1",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
@@ -5788,9 +5788,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -6715,6 +6715,7 @@
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7524,9 +7525,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8413,9 +8414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -10305,9 +10306,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
||||
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -11340,9 +11341,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"version": "6.16.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
|
||||
"integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
@@ -11601,12 +11602,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.0.tgz",
|
||||
"integrity": "sha512-CvY+PV+NBhxe3BnjFI5f//vEDKLULm9OlZArso7gdgVsWvanBuq9PNDx+/2llfL+8XFYa/UNBxvqRBg3TWU2Ug==",
|
||||
"version": "2.18.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz",
|
||||
"integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"is-plain-object": "^5.1.0",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"sanitize-html": "^2.18.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
@@ -90,7 +90,7 @@
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "^2.4.0"
|
||||
"multer": "^2.5.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
|
||||
|
||||
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰에서 쓰는 ARA SYSTEM 앱이 나왔습니다. 메일을 읽고 쓰고 찾을 수 있고, 답장과 전달, 첨부 파일 보내기도 됩니다.
|
||||
- 앱 받기: PC 웹메일에서 [설정] → [휴대폰 앱]을 열고 휴대폰 카메라로 QR 코드를 찍어 주세요.
|
||||
휴대폰 브라우저에서 웹메일에 로그인한 뒤 [앱 내려받기]를 누르고, 설치를 묻는 안내가 나오면 허용해 주세요.
|
||||
- 앱에는 웹메일과 같은 메일 주소와 비밀번호로 로그인합니다. 2단계 인증을 켜 두셨다면 인증 앱의 코드도 넣어 주세요.
|
||||
- 새 메일이 오면 휴대폰 알림으로 알려 드립니다. 앱의 [설정] → [새 메일 알림]에서 켜고 끌 수 있고,
|
||||
알림에는 새 메일이 몇 통인지만 나오며 보낸 사람이나 제목은 나오지 않습니다.
|
||||
- 아이폰용 앱은 아직 없습니다. 아이폰에서는 브라우저로 웹메일을 쓰거나 기본 메일 앱에 회사 메일을 연결해 주세요.
|
||||
연결 방법은 웹메일의 [설정] → [메일 프로그램 연결]에서 볼 수 있습니다.
|
||||
|
||||
## 웹메일과 휴대폰 앱
|
||||
- 메일 검색이 빨라졌고, 보낸 사람, 받는 사람, 제목, 본문, 기간, 첨부로 나눠 찾을 수도 있습니다.
|
||||
웹메일은 검색 칸의 [조건]을, 앱은 검색 화면의 [조건으로 찾기]를 눌러 주세요.
|
||||
- 찾은 말에는 노란 표시가 붙어 목록과 본문에서 금방 찾을 수 있습니다.
|
||||
- 메일을 읽다가 [이동]을 누르면 바로 다른 메일함으로 옮길 수 있습니다.
|
||||
- 업데이트 뒤 하루쯤은 새 검색을 준비하느라 검색 화면에 안내가 나올 수 있습니다. 그동안에도 찾기는 됩니다.
|
||||
|
||||
## 웹메일 · 메일 읽기와 정리
|
||||
- 메일을 열면 그 메일과 주고받은 메일이 아래에 대화로 모여 나옵니다.
|
||||
[모두 펼치기]로 한꺼번에 읽고, 펼친 메일의 [답장]으로 바로 답할 수 있습니다.
|
||||
- 목록 위의 [대화로 묶기]를 켜면 메일 목록도 대화별로 묶입니다.
|
||||
- 목록의 메일을 끌어다 왼쪽 메일함에 놓으면 그 메일함으로 옮겨집니다.
|
||||
- 메일함 이름 옆 [⋯] → [옮기기]로 메일함을 다른 메일함 아래로 옮길 수 있습니다.
|
||||
하위 메일함이 있는 메일함도 [⋯] → [지우기]로 한꺼번에 지울 수 있고, 안에 있던 메일은 휴지통으로 옮겨집니다.
|
||||
- 보낸 사람을 차단할 때 받은편지함에 있는 그 사람의 메일도 스팸메일함으로 함께 옮길 수 있습니다.
|
||||
메일을 읽다가 [⋯] → [보낸 사람 차단]을 누르고, 창에서 받은편지함의 메일도 옮기는 칸을 켜 주세요.
|
||||
- 예약한 메일을 기다리지 않고 바로 보낼 수 있습니다. 왼쪽의 [예약됨]에서 메일을 열고 [지금 보내기]를 눌러 주세요.
|
||||
|
||||
## 웹메일 · 주소록
|
||||
- 다른 메일이나 휴대폰에서 내보낸 연락처 파일을 한 번에 가져올 수 있습니다.
|
||||
[주소록]에서 [가져오기]를 누르고 파일을 고르면, 넣기 전에 몇 명이 들어가는지 먼저 확인할 수 있습니다.
|
||||
- vCard, CSV, 엑셀 파일을 넣을 수 있고, 네이버, Google, 아이폰, Outlook에서 내보낸 파일도 됩니다.
|
||||
- 이미 주소록에 있는 연락처는 건너뜁니다. 넣은 뒤 나오는 알림의 [되돌리기]를 누르면 이번에 넣은 연락처를 한꺼번에 지울 수 있습니다.
|
||||
- 내 주소록을 파일로 내보낼 수도 있습니다. [주소록]에서 [내보내기]를 누르고 vCard나 CSV를 골라 주세요. 그룹 하나만 내보낼 수도 있습니다.
|
||||
|
||||
## 웹메일 · 설정
|
||||
- 서명을 10개까지 만들어 두고 골라 쓸 수 있습니다. [설정] → [서명]에서 [새 서명]으로 만들고,
|
||||
[메일에 넣을 서명]에서 [새 메일]과 [답장과 전달]에 넣을 서명을 따로 고를 수 있습니다.
|
||||
- 메일을 쓰다가 아래의 [서명]을 누르면 다른 서명으로 바꿀 수 있습니다.
|
||||
- 부재중 자동 응답을 굵게, 색깔 같은 글자 모양으로 꾸며 쓸 수 있습니다. [설정] → [부재중 자동 응답]
|
||||
- 부재중 자동 응답에서 회사 밖에서 온 메일에는 [사내와 같은 응답], [보내지 않기], [따로 쓰기] 가운데 골라 답할 수 있습니다.
|
||||
- 휴대폰을 바꾸셨다면 2단계 인증을 새 휴대폰으로 직접 옮길 수 있습니다.
|
||||
[설정] → [2단계 인증] → [휴대폰 바꾸기]를 누르고 안내를 따라 주세요. 지금 휴대폰의 코드와 비밀번호가 필요합니다.
|
||||
|
||||
## 그 밖에
|
||||
- 화면의 안내 글을 더 알기 쉬운 말로 다듬었습니다.
|
||||
- 앞으로 새로 생기거나 바뀌는 것은 이 업데이트 내역에 올립니다. 웹메일과 앱 모두 [설정] → [업데이트 내역]에서 볼 수 있습니다.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 업데이트 내역 글
|
||||
|
||||
직원이 웹메일과 휴대폰 앱의 설정에서 읽는 「업데이트 내역」의 글입니다. 글 하나가 파일 하나이고, 웹과 앱이 같은 글을 보여 줍니다. 올리는 화면은 없습니다.
|
||||
|
||||
## 글을 더하는 법
|
||||
|
||||
1. 이 폴더에 파일을 하나 더합니다. 파일 이름은 글의 날짜입니다(`2026-11-18.md`). 같은 날 두 번째 글은 `2026-11-18-2.md`로 씁니다.
|
||||
2. 커밋하고 배포합니다(백엔드를 다시 빌드). 웹과 앱에 함께 나옵니다. Gitea 웹 화면에서 파일을 더하거나 고쳐도 됩니다.
|
||||
|
||||
고치거나 지울 때도 그 파일만 다룹니다. 목록에는 최근 글이 위에 나옵니다(날짜 차례, 같은 날은 뒤 번호가 위).
|
||||
|
||||
## 파일 안
|
||||
|
||||
```
|
||||
# 메일 검색이 빨라지고 휴대폰 앱이 나왔습니다
|
||||
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꿉니다 -->
|
||||
|
||||
## 휴대폰 앱
|
||||
- 안드로이드 휴대폰 앱이 나왔습니다. 웹메일의 [설정] → [휴대폰 앱]에서 받을 수 있습니다.
|
||||
- 앱에서 메일을 읽고 쓰고 찾을 수 있고, 새 메일이 오면 휴대폰으로 알려 드립니다.
|
||||
알림에는 새 메일이 몇 통인지만 나오고, 보낸 사람이나 제목은 나오지 않습니다.
|
||||
```
|
||||
|
||||
| 줄 | 뜻 |
|
||||
| --- | --- |
|
||||
| `# 제목` | 파일의 첫 줄이고 꼭 있어야 합니다. 목록의 한 줄과 글 화면의 제목이 됩니다(100자까지). |
|
||||
| `## 머리` | 묶음 머리입니다. 휴대폰 앱, 메일 검색처럼 어디 이야기인지 씁니다(40자까지). |
|
||||
| `- 한 줄` | 앞에 점이 붙은 한 줄입니다(500자까지). 쓴 글이 그대로 나옵니다. |
|
||||
| (빈칸 두 개) 글 | 앞 줄에 띄어쓰기 하나로 이어 붙습니다. 긴 줄을 파일에서 나눠 쓸 때 씁니다. |
|
||||
| `<!-- … -->` | 메모입니다. 화면에 나오지 않습니다. 줄 앞에서 시작합니다(앞에 빈칸은 있어도 되지만, 글 뒤에 붙여 쓰지는 않습니다). 여러 줄로 써도 됩니다. |
|
||||
| `- `(머리 없이) | 머리 없는 묶음입니다. 고친 것 한두 줄처럼 짧은 글에 씁니다. 제목 바로 아래에 씁니다. |
|
||||
|
||||
## 지킬 것
|
||||
|
||||
- 날짜는 파일 이름에서 읽으니 파일 안에 다시 쓰지 않습니다. 있는 날짜여야 합니다(`2026-02-30.md`는 안 됩니다).
|
||||
- 파일 이름은 글 주소가 됩니다. 날짜를 고치면 주소도 바뀌어, 그전 주소로 들어오면 없는 글 화면이 나옵니다.
|
||||
- 글 하나에 줄(`- `)이 하나는 있어야 하고, 묶음 머리 아래에도 줄이 하나는 있어야 합니다.
|
||||
- 굵게, 링크, 그림, 표, `###`, 하위 목록은 쓸 수 없습니다. 쓴 글이 그대로 나옵니다. 단추나 메뉴 이름은 `[설정] → [휴대폰 앱]`처럼 씁니다.
|
||||
- 이 폴더에는 이 안내(`README.md`)와 글 파일만 둡니다. 파일은 UTF-8로 저장합니다.
|
||||
- 형식이 틀린 글은 시험(CI)이 막습니다. 미리 보려면 backend 폴더에서 `npm run test -- release-notes`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -8,10 +8,18 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { ClientIpThrottlerGuard } from '@/common/guards/client-ip-throttler.guard';
|
||||
import { OriginGuard } from '@/common/guards/origin.guard';
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { AppConfigModule } from '@/modules/app-config/app-config.module';
|
||||
import { AppReleasesModule } from '@/modules/app-releases/app-releases.module';
|
||||
import { AuthModule } from '@/modules/auth/auth.module';
|
||||
import { ContactsModule } from '@/modules/contacts/contacts.module';
|
||||
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 { PushModule } from '@/modules/push/push.module';
|
||||
import { ReleaseNotesModule } from '@/modules/release-notes/release-notes.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';
|
||||
@@ -64,10 +72,26 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
HealthModule,
|
||||
// 직원 로그인(쿠키 JWT) — 자격 확인은 MailModule 의 메일 엔진 포트로
|
||||
AuthModule,
|
||||
// 계정 보안 (12단계) — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기 (인증 모듈도 가져온다)
|
||||
AccountSecurityModule,
|
||||
// 웹메일 API (Stalwart JMAP 어댑터)
|
||||
MailModule,
|
||||
// 주소록 API — 내 주소록 · 사내 목록 (8단계, 메일 모듈도 받는 사람 추천에 쓴다)
|
||||
ContactsModule,
|
||||
// 대용량 첨부 (11단계) — 파일 · 조각 올리기 · 악성 검사(FileStorageModule)와 공개 링크 · 내려받기(FileSharingModule).
|
||||
// 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다
|
||||
FileStorageModule,
|
||||
FileSharingModule,
|
||||
// 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다
|
||||
SystemSettingsModule,
|
||||
// 앱 알림 (앱 1단계) — 등록(/api/push-registrations/current) · 끝난 세션 정리 · 보내기(FCM). 새 메일을 알아채는 쪽은 메일 모듈이다
|
||||
PushModule,
|
||||
// 앱 설정 (앱 1단계) — GET /api/app-config (로그인 없음 — 최소 판 · 알림을 보낼 수 있는지)
|
||||
AppConfigModule,
|
||||
// 휴대폰 앱 받기 (사내 배포) — 최신 판 정보 · APK 파일 (로그인 필요). 올리기는 운영 도구다
|
||||
AppReleasesModule,
|
||||
// 업데이트 내역 — 목록 · 글 (로그인 필요). 글은 저장소의 backend/release-notes 폴더(글 파일 하나가 글 하나), 올리는 API 는 없다
|
||||
ReleaseNotesModule,
|
||||
UploadModule,
|
||||
UserModule,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { BadRequestException, HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ExpressAdapter,
|
||||
NestExpressApplication,
|
||||
} from '@nestjs/platform-express';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import request from 'supertest';
|
||||
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
@@ -9,6 +20,8 @@ import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto';
|
||||
import { SendMessageDto } from '@/modules/mail/dto/send-message.dto';
|
||||
import {
|
||||
JSON_SHAPE_LIMITS,
|
||||
configureApp,
|
||||
createHttpAdapter,
|
||||
createValidationPipe,
|
||||
isDraftSaveRequest,
|
||||
isSendMessageRequest,
|
||||
@@ -273,7 +286,7 @@ describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () =>
|
||||
});
|
||||
|
||||
describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다', () => {
|
||||
it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 끝의 / ·쿼리는 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404)', () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/drafts'],
|
||||
['POST', '/API/Drafts/'],
|
||||
@@ -301,7 +314,7 @@ describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () =>
|
||||
});
|
||||
|
||||
describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 대소문자·끝의 / ·쿼리는 가리지 않는다 (라우터와 같다)', () => {
|
||||
it('POST /api/messages 는 발송 요청이다 — 끝의 / ·쿼리는 라우터처럼 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404 — 크기 때문에 413 이 되지 않게)', () => {
|
||||
for (const url of [
|
||||
'/api/messages',
|
||||
'/api/messages/',
|
||||
@@ -331,3 +344,69 @@ describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** 라우팅 대소문자 시험용 처리기 — 받기 경로와 같은 모양 */
|
||||
@Controller('probe-routes')
|
||||
class ProbeRoutesController {
|
||||
@Get(':id/large-files/:fileId')
|
||||
download(): string {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
|
||||
describe('createHttpAdapter — 라우팅은 대소문자를 가린다 (2026-10-30 인프라 리뷰 High)', () => {
|
||||
const apps: NestExpressApplication[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
/** 처리기 하나만 둔 앱 — 어댑터를 주면 그것으로, 아니면 Nest 의 기본 어댑터로 */
|
||||
const createProbeApp = async (
|
||||
adapter?: ExpressAdapter,
|
||||
): Promise<NestExpressApplication> => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [ProbeRoutesController],
|
||||
}).compile();
|
||||
const app = adapter
|
||||
? moduleRef.createNestApplication<NestExpressApplication>(adapter, {
|
||||
logger: false,
|
||||
})
|
||||
: moduleRef.createNestApplication<NestExpressApplication>({
|
||||
logger: false,
|
||||
});
|
||||
apps.push(app);
|
||||
return app;
|
||||
};
|
||||
|
||||
it('소문자 경로만 처리기에 닿는다 — 대소문자 변형은 404', async () => {
|
||||
const app = await createProbeApp(createHttpAdapter());
|
||||
await app.init();
|
||||
const server = app.getHttpServer();
|
||||
await request(server).get('/probe-routes/a/large-files/b').expect(200);
|
||||
for (const path of [
|
||||
'/Probe-Routes/a/large-files/b',
|
||||
'/probe-routes/a/LARGE-FILES/b',
|
||||
'/PROBE-ROUTES/a/large-files/b',
|
||||
]) {
|
||||
await request(server).get(path).expect(404);
|
||||
}
|
||||
});
|
||||
|
||||
it('어댑터를 만든 뒤에 켜면 늦다 — Nest 의 ExpressAdapter 가 만들어질 때 라우터를 이미 만들어 설정 값만 바뀐다', async () => {
|
||||
const app = await createProbeApp();
|
||||
app.set('case sensitive routing', true);
|
||||
await app.init();
|
||||
await request(app.getHttpServer())
|
||||
.get('/Probe-Routes/a/large-files/b')
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('configureApp 은 대소문자를 가리지 않는 라우터로 만든 앱이면 멈춘다 — 기본 어댑터 · 만든 뒤에 켠 설정 모두', async () => {
|
||||
const plain = await createProbeApp();
|
||||
expect(() => configureApp(plain)).toThrow(/createHttpAdapter/);
|
||||
|
||||
const late = await createProbeApp();
|
||||
late.set('case sensitive routing', true);
|
||||
expect(() => configureApp(late)).toThrow(/createHttpAdapter/);
|
||||
});
|
||||
});
|
||||
|
||||
+101
-7
@@ -1,8 +1,17 @@
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import {
|
||||
ExpressAdapter,
|
||||
type NestExpressApplication,
|
||||
} from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { json, type NextFunction, type Request, type Response } from 'express';
|
||||
import express, {
|
||||
json,
|
||||
type Express,
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import helmet from 'helmet';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
@@ -10,6 +19,21 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
|
||||
import { requestIdMiddleware } from '@/common/middleware/request-id.middleware';
|
||||
import { requestTargetMiddleware } from '@/common/middleware/request-target.middleware';
|
||||
import { MAIL_ENGINE_EVENTS_SHAPE_LIMITS } from '@/modules/account-security/account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
} from '@/modules/account-security/account-security.settings';
|
||||
import {
|
||||
createMailEngineEventsBodyParser,
|
||||
isMailEngineEventsRequest,
|
||||
} from '@/modules/account-security/mail-engine-events.parser';
|
||||
import {
|
||||
FILE_SHARING_SETTINGS,
|
||||
type FileSharingSettings,
|
||||
} from '@/modules/file-sharing/file-sharing.settings';
|
||||
import { createPublicHostMiddleware } from '@/modules/file-sharing/public-host';
|
||||
import { DRAFTS_ROUTE, MESSAGES_ROUTE } from '@/modules/mail/mail.constants';
|
||||
import {
|
||||
findJsonShapeProblem,
|
||||
@@ -50,17 +74,55 @@ export const JSON_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
maxDepth: 32,
|
||||
};
|
||||
|
||||
/** Express 설정 이름 — 라우팅이 대소문자를 가린다 (createHttpAdapter) */
|
||||
const CASE_SENSITIVE_ROUTING = 'case sensitive routing';
|
||||
|
||||
/**
|
||||
* 앱을 만들 HTTP 어댑터 — main.ts 와 e2e 가 **같은 함수**를 쓴다(NestFactory.create · createNestApplication 의 어댑터 인자).
|
||||
*
|
||||
* 라우팅은 **대소문자를 가린다** — `/api/Messages/…` · `…/LARGE-FILES/…` · `/api/public-links/…/FILES/…` · `/API/…` 는 처리기에 닿지 않고 404 RES_001.
|
||||
* 대소문자를 가리지 않으면 앞단 nginx 의 받기 location(대소문자를 가리는 정규식 · prefix)이 놓친 변형이 받기 처리기까지 와서
|
||||
* 동시 연결 상한(limit_conn)과 버퍼링 끄기를 비켜 갔다(2026-10-30 인프라 리뷰 High — nginx 도 고친다). 클라이언트(웹 · 앱)는 모두 소문자 경로다.
|
||||
*
|
||||
* Express 5 는 라우터를 처음 꺼낼 때 만들며 그때 이 설정을 읽는데, Nest 의 ExpressAdapter 는 만들어질 때 이미 `app.use` 를 부른다
|
||||
* (express-adapter.js 의 생성자) — 그래서 configureApp() 에서 켜면 늦다(2026-10-30 확인: express 5.2.1 · router 2.2.0 · @nestjs/platform-express 11.2.5).
|
||||
* Express 인스턴스를 직접 만들어 어댑터보다 먼저 켠다. configureApp 이 맨 앞에서 라우터가 정말 대소문자를 가리는지 확인한다
|
||||
*/
|
||||
export function createHttpAdapter(): ExpressAdapter {
|
||||
const instance = express();
|
||||
instance.enable(CASE_SENSITIVE_ROUTING);
|
||||
return new ExpressAdapter(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 앱의 라우터가 대소문자를 가리는가 — Express 5 의 `app.router` 가 만들어질 때 받은 값(router 패키지가 `caseSensitive` 로 둔다).
|
||||
* 설정 값(`app.enabled`)이 아니라 라우터를 본다 — 앱을 만든 뒤에 설정을 켜면 값은 켜져도 라우터는 그대로라서다.
|
||||
* router 가 이 이름을 바꾸면 여기서 기동이 멈춘다 — 그때는 e2e 의 대소문자 테스트로 확인하고 고친다
|
||||
*/
|
||||
function routesAreCaseSensitive(app: NestExpressApplication): boolean {
|
||||
const instance: unknown = app.getHttpAdapter().getInstance();
|
||||
const router: unknown = (instance as Partial<Express> | null)?.router;
|
||||
return (
|
||||
typeof router === 'function' &&
|
||||
(router as { caseSensitive?: unknown }).caseSensitive === true
|
||||
);
|
||||
}
|
||||
|
||||
const SEND_MESSAGE_PATH = `/${API_PREFIX}/${MESSAGES_ROUTE}`;
|
||||
const DRAFTS_PATH = `/${API_PREFIX}/${DRAFTS_ROUTE}`;
|
||||
|
||||
/** 라우터가 보는 경로 — Express 처럼 대소문자·끝의 '/' 를 가리지 않고 쿼리는 뺀다 */
|
||||
/**
|
||||
* 본문 한도를 고를 경로 — 쿼리를 빼고, 라우터처럼 끝의 '/' 는 가리지 않는다.
|
||||
* 대소문자는 가리지 않는다 — 라우터는 가리지만(createHttpAdapter) 본문 한도는 넓게 고른다: 발송 · 임시 저장 경로의 대소문자 변형도 같은 4MB 파서로 읽은 뒤
|
||||
* 라우터에서 404 RES_001 이 된다(본문 크기 때문에 413 이 되지 않고 다른 대소문자 변형처럼 404 로 같게)
|
||||
*/
|
||||
function routedPath(req: Pick<IncomingMessage, 'url'>): string {
|
||||
return (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 요청인가 — `POST /api/messages`.
|
||||
* 라우터가 발송 핸들러로 보내는 모양이면 모두 해당한다.
|
||||
* 라우터가 발송 핸들러로 보내는 모양(끝의 '/' · 쿼리)이면 모두 해당하고, 대소문자 변형도 넓게 해당한다 — 그 변형은 라우터에서 404 (위 routedPath).
|
||||
*/
|
||||
export function isSendMessageRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
@@ -130,7 +192,8 @@ export function verifyJsonBody(
|
||||
/**
|
||||
* 본문 파서가 읽은 JSON 본문의 모양을 본다 (JSON_SHAPE_LIMITS) — 넘으면 DTO 변환 · 검사로 넘기지 않고 400 VAL_001.
|
||||
* 지금은 읽기 전 확인을 지난 UTF-8 본문만 오므로 같은 판정을 한 번 더 하는 셈이다(값 10,000개 안이라 가볍다) —
|
||||
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다
|
||||
* 본문 파서를 새로 붙이거나 읽기 전 확인이 빠져도 DTO 변환 · 검사 전에 끊도록 남겨 둔다.
|
||||
* 엔진 웹훅(12단계 — `POST /api/internal/mail-engine-events`)만 그 경로의 한도(MAIL_ENGINE_EVENTS_SHAPE_LIMITS)로 본다
|
||||
*/
|
||||
export function rejectOversizedJsonBody(
|
||||
req: Request,
|
||||
@@ -138,7 +201,10 @@ export function rejectOversizedJsonBody(
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const body: unknown = req.body;
|
||||
const problem = findJsonShapeProblem(body, JSON_SHAPE_LIMITS);
|
||||
const limits = isMailEngineEventsRequest(req)
|
||||
? MAIL_ENGINE_EVENTS_SHAPE_LIMITS
|
||||
: JSON_SHAPE_LIMITS;
|
||||
const problem = findJsonShapeProblem(body, limits);
|
||||
if (problem === null) {
|
||||
next();
|
||||
return;
|
||||
@@ -173,14 +239,33 @@ function isJsonBody(req: IncomingMessage): boolean {
|
||||
* HTTP 파이프라인 전역 설정 — main.ts 와 e2e 테스트가 **같은 함수**를 쓴다
|
||||
* (따로 적으면 e2e 가 실제 응답과 다른 설정으로 돌게 된다).
|
||||
*
|
||||
* 앱은 `{ bodyParser: false }` 로 만들어야 한다 — 본문 파서를 여기서 직접 등록한다.
|
||||
* 앱은 `createHttpAdapter()` 와 `{ bodyParser: false }` 로 만들어야 한다 — 라우팅의 대소문자 구분은 어댑터를 만들 때 켜고(여기서는 늦다),
|
||||
* 본문 파서는 여기서 직접 등록한다. 대소문자를 가리지 않는 라우터로 만든 앱이면 기동을 멈춘다.
|
||||
* CORS·Swagger·종료 훅은 배포 환경에 묶인 설정이라 main.ts 에 둔다.
|
||||
*/
|
||||
export function configureApp(app: NestExpressApplication): void {
|
||||
if (!routesAreCaseSensitive(app)) {
|
||||
throw new Error(
|
||||
'라우팅이 대소문자를 가리지 않습니다 — 앱은 createHttpAdapter() 로 만든 어댑터로 만들어야 합니다(app.setup.ts). ' +
|
||||
'예: NestFactory.create(AppModule, createHttpAdapter(), { bodyParser: false })',
|
||||
);
|
||||
}
|
||||
|
||||
// 요청 추적 ID — **가장 먼저** 붙인다. 본문 파서·helmet 보다 앞이라 그 단계의 실패(413·400·415)에도
|
||||
// 응답 traceId 와 로그가 남고, 이후 모든 처리가 요청 컨텍스트(traceTag) 안에서 돈다
|
||||
app.use(requestIdMiddleware);
|
||||
|
||||
// 요청 대상 원문 검사 — 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 라우팅 전에 400 VAL_001 (common/middleware/request-target.middleware.ts).
|
||||
// Express 의 parseurl 이 그런 대상을 옛 url.parse 로 다시 읽으며 `\` 를 `/` 로 바꿔, 앞단 nginx 가 정규식으로 닫은 경로가 여기서 열렸다 (2026-10-31)
|
||||
app.use(requestTargetMiddleware);
|
||||
|
||||
// 공개 호스트(대용량 첨부의 내려받기 주소 — 11단계)를 포털과 따로 두면 그 호스트에서는 공개 링크 API 만, 포털에서는 공개 링크 API 를 닫는다.
|
||||
// 본문 파서 앞이라 막은 요청의 본문을 읽지 않는다. 같은 주소면(개발) 아무것도 막지 않는다 (modules/file-sharing/public-host.ts)
|
||||
const sharing = app.get<FileSharingSettings>(FILE_SHARING_SETTINGS, {
|
||||
strict: false,
|
||||
});
|
||||
app.use(createPublicHostMiddleware(sharing.dedicatedPublicHost));
|
||||
|
||||
// 믿을 프록시 뒤에서 실제 클라이언트 IP 를 읽는다 — 로그인 실패 한도·요청 수 한도의 기준 (TRUST_PROXY).
|
||||
// 운영 체인: 클라이언트 → 외부 리버스 프록시 → 프론트 nginx → 백엔드. 값이 없으면(비운영) 1
|
||||
app.set('trust proxy', resolveTrustProxy(process.env.TRUST_PROXY));
|
||||
@@ -205,6 +290,15 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// 순서가 중요하다: 4MB 파서가 먼저여야 발송 본문이 1MB 파서에서 413 이 되지 않는다.
|
||||
// 두 파서 모두 읽기 전 확인(verify — UTF-8 만 · 모양 한도)을 건다 — Nest 의 useBodyParser 는 verify 를 rawBody 용으로 남겨 두고 받지 않아(타입에서 뺀다)
|
||||
// 같은 파서(express.json = body-parser)를 직접 붙인다 (useBodyParser 도 안에서 이렇게 붙인다)
|
||||
//
|
||||
// 엔진 웹훅(12단계 — 계약 5절 "엔진 웹훅 받기")은 맨 앞의 자기 파서가 읽는다: 본문을 받기 전에 소켓 주소(앱 네트워크) · 서명 키 유무를 보고
|
||||
// 아니면 본문을 받지 않고 401 AUTH_001(연결을 닫는다), 서명(HMAC)은 원문을 받은 뒤 JSON 으로 읽기 전에 본다.
|
||||
// 한도는 4MB · 배열 10,000 · 값 200,000 (modules/account-security/mail-engine-events.parser.ts)
|
||||
const accountSecurity = app.get<AccountSecuritySettings>(
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
{ strict: false },
|
||||
);
|
||||
app.use(createMailEngineEventsBodyParser(accountSecurity));
|
||||
app.use(
|
||||
json({
|
||||
limit: SEND_MESSAGE_JSON_BODY_LIMIT,
|
||||
|
||||
@@ -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 =
|
||||
'로그인 필요 (쿠키 · 앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 웹 · 앱 토큰을 바꿔 씀) — 클라이언트는 갱신(웹 /auth/refresh · 앱 /auth/app/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,13 @@ 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';
|
||||
|
||||
/**
|
||||
* 로그인 세션을 연 클라이언트 (계약 1절 "앱 로그인") — 웹(브라우저 · 쿠키) · 앱(모바일 · Bearer).
|
||||
* 세션 표의 `client` 칸과 같은 값이다. 세션 규칙은 같고 토큰을 나르는 길만 다르다
|
||||
*/
|
||||
export type SessionClient = 'web' | 'app';
|
||||
|
||||
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
|
||||
export interface AuthenticatedUser {
|
||||
@@ -14,6 +21,26 @@ export interface AuthenticatedUser {
|
||||
* 로그인 · 갱신 응답의 직원 정보에는 없다. 오래 열린 연결(실시간 알림)을 토큰이 끝나기 전에 닫는 데 쓴다
|
||||
*/
|
||||
readonly accessTokenExpiresAt?: number;
|
||||
/**
|
||||
* 이 액세스 토큰의 로그인 세션 id (auth_sessions.id) — 가드를 거친 요청에만, 12단계 뒤에 발급한 토큰에만 있다.
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
/**
|
||||
* 앱 토큰(`Authorization: Bearer` — 표시 `cli: 'app'`)으로 들어온 요청이면 'app' — 가드를 거친 요청에만 있다. 웹(쿠키)이면 없다.
|
||||
* 앱 요청은 리프레시 쿠키가 없으므로 "지금 세션"을 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
|
||||
*/
|
||||
readonly client?: SessionClient;
|
||||
/**
|
||||
* 제한된 세션이면 그 제한 (계약 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Header } from '@nestjs/common';
|
||||
|
||||
/** 공유 캐시 · 브라우저에 남기지 않는다 — 내 파일 · 링크처럼 직원만 보는 응답 (계약 4절 "공개 경로 밖의 응답은 private, no-store") */
|
||||
export const PRIVATE_NO_STORE = 'private, no-store';
|
||||
|
||||
/** 성공 응답에 `Cache-Control: private, no-store` 를 붙인다 (핸들러마다) */
|
||||
export const NoStore = (): MethodDecorator =>
|
||||
Header('Cache-Control', PRIVATE_NO_STORE);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -82,6 +88,29 @@ describe('에러 코드 레지스트리', () => {
|
||||
expect(ERROR_CODES.CONTACT_002.status).toBe(HttpStatus.CONFLICT);
|
||||
});
|
||||
|
||||
it('대용량 첨부(11단계) — 상태가 맞지 않음 409 FILE_003 · 조각 손상 400 FILE_004 · 보관 한도 409 FILE_005 · 서버 공간 507 FILE_006 · 더 받을 수 없음 410 FILE_007 · 검사 중 409 FILE_008 · 쓸 수 없는 파일 400 MAIL_016', () => {
|
||||
expect(ERROR_CODES.FILE_003.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.FILE_004.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(ERROR_CODES.FILE_005.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.FILE_006.status).toBe(HttpStatus.INSUFFICIENT_STORAGE);
|
||||
expect(ERROR_CODES.FILE_007.status).toBe(HttpStatus.GONE);
|
||||
expect(ERROR_CODES.FILE_008.status).toBe(HttpStatus.CONFLICT);
|
||||
expect(ERROR_CODES.MAIL_016.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
});
|
||||
|
||||
it('410 · 507 은 상태 역매핑에 넣지 않는다 — FILE_007 · FILE_006 은 BusinessException 으로만 나온다', () => {
|
||||
expect(resolveCodeByStatus(HttpStatus.GONE)).toBe('BIZ_001');
|
||||
expect(resolveCodeByStatus(HttpStatus.INSUFFICIENT_STORAGE)).toBe(
|
||||
'SYS_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('대용량 첨부 한도 문구에는 숫자가 없다 — 한도가 env 로 바뀐다', () => {
|
||||
for (const code of ['FILE_005', 'FILE_006', 'MAIL_016'] as const) {
|
||||
expect(ERROR_CODES[code].message).not.toMatch(/\d/);
|
||||
}
|
||||
});
|
||||
|
||||
it('클라이언트 전용 코드(AUTH_002)는 서버가 상태 역매핑으로 선택하지 않는다', () => {
|
||||
const clientOnlyCodes = Object.entries(ERROR_CODES)
|
||||
.filter(([, definition]) => 'clientOnly' in definition)
|
||||
|
||||
@@ -20,30 +20,32 @@ export interface ErrorCodeDefinition {
|
||||
const COMMON_ERROR_CODES = {
|
||||
SYS_001: {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'문제가 생겨 하던 일을 마치지 못했습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
REQ_001: {
|
||||
status: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message:
|
||||
'너무 여러 번 시도했거나 같은 일이 이미 진행 중입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_001: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
AUTH_002: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
message: '안전을 위해 로그아웃했습니다. 다시 로그인해 주세요.',
|
||||
// 세션 만료는 클라이언트가 "인증된 세션이 있었는데 401 을 받은" 상황에서 생성한다.
|
||||
clientOnly: true,
|
||||
},
|
||||
AUTH_003: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
message: '이 기능을 쓸 수 있는 권한이 없습니다.',
|
||||
},
|
||||
// 로그인 자격 증명 불일치 — 계정 없음/비밀번호 틀림을 구분하지 않는다 (.claude/rules/security.md)
|
||||
AUTH_004: {
|
||||
status: HttpStatus.UNAUTHORIZED,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.',
|
||||
message: '메일 주소나 비밀번호가 맞지 않습니다. 다시 확인해 주세요.',
|
||||
},
|
||||
// 비밀번호 바꾸기(PUT /api/auth/password)의 지금 비밀번호가 틀림 — 세션은 그대로다.
|
||||
// 401 이 아니라 400 인 까닭: 웹 클라이언트는 401 이면 세션을 갱신하고 같은 요청을 다시 보낸다 (계약 1절)
|
||||
@@ -51,17 +53,48 @@ const COMMON_ERROR_CODES = {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '지금 비밀번호가 올바르지 않습니다.',
|
||||
},
|
||||
// 2단계 인증 코드가 틀렸거나 2분 안에 다시 쓴 코드 — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기 (계약 5절).
|
||||
// 401 이 아닌 까닭은 AUTH_005 와 같다(같은 코드를 로그인한 채 쓰는 설정에서도 쓴다)
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '인증 앱의 코드를 입력해 주세요.',
|
||||
},
|
||||
// 2단계 인증 등록이 없음 · 10분 지남 · 그사이 다른 곳에서 바뀜 — 화면은 처음부터 다시 (계약 5절)
|
||||
AUTH_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.',
|
||||
},
|
||||
// 앱 비밀번호 상한(엔진 기본 5개) — 엔진 상한을 바꾸면 이 문구와 화면 상수도 함께 (계약 5절)
|
||||
AUTH_009: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.',
|
||||
},
|
||||
// 제한된 세션(비밀번호 바꾸기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API 를 불렀다.
|
||||
// 화면은 알림 없이 me 를 다시 받아 그 제한의 단계로 간다 (계약 6절 "제한된 세션"). 403 의 상태 역매핑은 그대로 AUTH_003 이다
|
||||
AUTH_010: {
|
||||
status: HttpStatus.FORBIDDEN,
|
||||
message: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.',
|
||||
},
|
||||
VAL_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
message:
|
||||
'입력하신 내용을 다시 확인해 주세요. 빠진 칸이 있거나 형식이나 길이가 맞지 않습니다.',
|
||||
},
|
||||
RES_001: {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
message: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
message: '찾을 수 없습니다. 이미 지워졌거나 옮겨졌을 수 있습니다.',
|
||||
},
|
||||
BIZ_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
message: '하려던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
@@ -78,16 +111,52 @@ const COMMON_ERROR_CODES = {
|
||||
const DOMAIN_ERROR_CODES = {
|
||||
FILE_001: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '허용되지 않는 파일 형식입니다. 파일을 확인해 주세요.',
|
||||
message:
|
||||
'쓸 수 없는 형식의 파일입니다. 그 파일을 빼거나 다른 파일로 바꿔 주세요.',
|
||||
},
|
||||
FILE_002: {
|
||||
status: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
message: '파일 크기가 허용 범위를 초과했습니다.',
|
||||
message: '파일이 너무 커서 올릴 수 없습니다.',
|
||||
},
|
||||
// 대용량 첨부(11단계 — 계약 4절) — 올리기 · 파일의 지금 상태와 맞지 않는 요청: 끝낸 올리기에 조각 · 같은 조각을 동시에 · 받은 조각과 내용이 다른 조각 ·
|
||||
// 빠진 조각이 있는데 끝내기 · 끝내기가 진행 중 · 살아 있는 링크에 실린 파일 지우기. 아무것도 바꾸지 않았다 — 클라이언트는 GET 으로 상태를 보고 잇는다
|
||||
FILE_003: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'대용량 첨부 보관 한도를 넘어 더 올릴 수 없습니다. 보낸 파일의 링크를 끊거나 보내지 않은 파일을 지우면 공간이 생깁니다.',
|
||||
},
|
||||
// 서버 저장 공간이 모자란다 — 서버 총량 · 디스크 여유 · 쓰다 ENOSPC. 507 은 클라이언트의 저절로 다시 보내기 대상(502 · 503 · 504)과 상태로 갈린다
|
||||
FILE_006: {
|
||||
status: HttpStatus.INSUFFICIENT_STORAGE,
|
||||
message:
|
||||
'회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기)
|
||||
FILE_007: {
|
||||
status: HttpStatus.GONE,
|
||||
message:
|
||||
'이 파일은 더 내려받을 수 없습니다. 필요하면 보낸 사람에게 문의해 주세요.',
|
||||
},
|
||||
// 아직 보안 검사(악성 검사) 중인 파일 — 끝나면 받는다
|
||||
FILE_008: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message: '보안 검사가 끝나지 않은 파일입니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
@@ -96,14 +165,14 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_003: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'메일함 용량이 부족합니다. 불필요한 메일을 정리한 뒤 다시 시도해 주세요.',
|
||||
'메일함이 가득 찼습니다. 필요 없는 메일을 지우고 휴지통을 비운 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 발송 때 엔진에서 첨부(blob)를 찾지 못함 — 올려 두고 오래 지나 만료된 업로드·그사이 지워진 원본 메일의 첨부(전달)·잘못된 첨부 id.
|
||||
// 문구는 원인을 단정하지 않는다 — 올린 파일은 다시 올리고, 전달하는 원본 첨부는 빼야 한다 (계약 2절)
|
||||
MAIL_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'첨부 파일을 찾을 수 없습니다. 해당 첨부를 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'첨부 파일을 찾을 수 없습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 같은 Idempotency-Key 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
|
||||
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
|
||||
@@ -116,7 +185,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 문구의 숫자는 attachment.policy.ts 의 MAX_ATTACHMENTS_PER_MESSAGE 와 같아야 한다 (attachment.policy.spec.ts 가 확인한다)
|
||||
MAIL_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '첨부는 한 메일에 최대 20개까지 보낼 수 있습니다.',
|
||||
message: '첨부 파일은 메일 한 통에 20개까지 보낼 수 있습니다.',
|
||||
},
|
||||
// 본문이 너무 길다 — 텍스트 100,000자·서식 본문 500,000자 초과, 서식 본문이 정화 전 비용 한도나 정화 뒤 2,000,000바이트를 넘음.
|
||||
// 메일은 나가지 않는다. 클라이언트는 보내기 전에 같은 한도로 미리 막고 본문 아래에 이 문구를 보인다 (계약 2절)
|
||||
@@ -160,7 +229,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
|
||||
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
@@ -176,6 +245,13 @@ const DOMAIN_ERROR_CODES = {
|
||||
message:
|
||||
'보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.',
|
||||
},
|
||||
// 보내거나 저장할 대용량 첨부(11단계 — 계약 4절) 파일을 쓸 수 없다 — 없음 · 다른 직원 것 · 지움 · 기간 지남, (보낼 때) 올리는 중 · 위험.
|
||||
// 메일은 나가지 않았다 · 저장하지 않았다. 클라이언트는 파일마다 GET /api/file-uploads/{id} 로 다시 물어 쓸 수 없는 것을 보인다 (MAIL_004 와 같은 흐름)
|
||||
MAIL_016: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
|
||||
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
|
||||
CONTACT_001: {
|
||||
@@ -187,7 +263,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
CONTACT_002: {
|
||||
status: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. (연락처 3,000개 · 그룹 100개 · 그룹 하나에 500명까지)',
|
||||
'주소록 한도를 넘어 더 넣을 수 없습니다. 연락처 3,000개, 그룹 100개, 그룹 하나에 500명까지 넣을 수 있습니다.',
|
||||
},
|
||||
} as const satisfies Record<string, ErrorCodeDefinition>;
|
||||
|
||||
|
||||
@@ -193,3 +193,63 @@ describe('HttpExceptionFilter', () => {
|
||||
expect(result.body.error).not.toHaveProperty('traceId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpExceptionFilter — 공개 경로 옵션 · 이미 쓰기 시작한 응답 (11단계)', () => {
|
||||
const hostFor = (response: object) =>
|
||||
({
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
method: 'GET',
|
||||
url: '/api/public-links/x',
|
||||
header: () => undefined,
|
||||
}),
|
||||
getResponse: () => response,
|
||||
}),
|
||||
}) as unknown as ArgumentsHost;
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('quietStatuses 의 실패는 debug 로만 — 형식은 그대로 표준 실패', () => {
|
||||
const debug = jest
|
||||
.spyOn(Logger.prototype, 'debug')
|
||||
.mockImplementation(() => undefined);
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const json = jest.fn();
|
||||
const response = { status: jest.fn().mockReturnValue({ json }) };
|
||||
|
||||
new HttpExceptionFilter({ quietStatuses: [404, 409, 410] }).catch(
|
||||
new BusinessException('FILE_007'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(response.status).toHaveBeenCalledWith(HttpStatus.GONE);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: { code: 'FILE_007', message: ERROR_CODES.FILE_007.message },
|
||||
});
|
||||
expect(debug).toHaveBeenCalledTimes(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
new HttpExceptionFilter({ quietStatuses: [404] }).catch(
|
||||
new BusinessException('REQ_001'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('응답을 이미 쓰기 시작했으면 실패 본문을 덧붙이지 않고 연결을 끊는다', () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const response = {
|
||||
headersSent: true,
|
||||
destroy: jest.fn(),
|
||||
status: jest.fn(),
|
||||
};
|
||||
new HttpExceptionFilter().catch(
|
||||
new Error('stream broke'),
|
||||
hostFor(response),
|
||||
);
|
||||
expect(response.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,9 +30,23 @@ const CLIENT_ERROR_STATUS_THRESHOLD = 400;
|
||||
* 에러 코드 ↔ 메시지 ↔ HTTP 상태는 error-codes.ts 레지스트리가 SSOT 이며,
|
||||
* 이 필터는 조회만 한다(여기에 문구를 하드코딩하지 말 것).
|
||||
*/
|
||||
/** 필터 옵션 — 컨트롤러에 따로 붙일 때만 쓴다 (전역 필터는 기본값) */
|
||||
export interface HttpExceptionFilterOptions {
|
||||
/**
|
||||
* debug 로만 남길 상태 코드 — 바깥 사람 · 봇이 잦게 부르는 공개 경로의 예상된 실패(없는 링크 404 · 검사 중 409 · 끝난 링크 410)가
|
||||
* warn 으로 쌓여 알림 피로가 되지 않게 (계약 4절 "공개 내려받기" — 외부 이미지 중계와 같은 결). 형식은 그대로 표준 실패다
|
||||
*/
|
||||
readonly quietStatuses?: readonly number[];
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
private readonly quietStatuses: ReadonlySet<number>;
|
||||
|
||||
constructor(options: HttpExceptionFilterOptions = {}) {
|
||||
this.quietStatuses = new Set(options.quietStatuses ?? []);
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
@@ -44,6 +58,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
|
||||
this.log(exception, status, code, request, traceId);
|
||||
|
||||
// 응답을 이미 쓰기 시작했다(파일을 흘려보내던 중 등) — 실패 본문을 덧붙일 수 없다. 연결을 끊어 받는 쪽이 실패로 알게 한다
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: { code, message, ...(traceId ? { traceId } : {}) },
|
||||
@@ -158,6 +178,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.quietStatuses.has(status)) {
|
||||
this.logger.debug(`${prefix}${context}`);
|
||||
return;
|
||||
}
|
||||
this.logger.warn(`${prefix}${context}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AppClientGuard } from './app-client.guard';
|
||||
|
||||
const contextOf = (user: AuthenticatedUser | undefined): ExecutionContext =>
|
||||
({
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
function codeOf(run: () => unknown): string | undefined {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('AppClientGuard (앱 토큰만 받는 경로)', () => {
|
||||
const guard = new AppClientGuard();
|
||||
const base = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
|
||||
it('앱 토큰(cli: app · 세션 id)이면 지난다', () => {
|
||||
expect(
|
||||
guard.canActivate(
|
||||
contextOf({ ...base, client: 'app', sessionId: 'sid-1' }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('웹 쿠키 세션 · 세션 id 없는 토큰은 403 AUTH_003', () => {
|
||||
expect(
|
||||
codeOf(() =>
|
||||
guard.canActivate(contextOf({ ...base, sessionId: 'sid-1' })),
|
||||
),
|
||||
).toBe('AUTH_003');
|
||||
expect(
|
||||
codeOf(() => guard.canActivate(contextOf({ ...base, client: 'app' }))),
|
||||
).toBe('AUTH_003');
|
||||
});
|
||||
|
||||
it('JwtAuthGuard 없이 걸어 사용자가 없으면 AUTH_001 — 조용히 지나가지 않는다', () => {
|
||||
expect(codeOf(() => guard.canActivate(contextOf(undefined)))).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 앱 토큰만 받는 경로 (계약 1절 "앱 새 메일 알림 · 앱 설정") — `JwtAuthGuard` **뒤에** 건다: `@UseGuards(JwtAuthGuard, AppClientGuard)`.
|
||||
* 앱 토큰(Bearer · 표시 cli: app · 세션 id)이 아니면 403 AUTH_003 — 토큰은 맞지만 이 경로를 쓸 수 없는 클라이언트(웹 쿠키 세션)다.
|
||||
* 웹 화면이 부를 일이 없어 사용자가 볼 일도 없다. 제한된 세션(AUTH_010) · 토큰 없음(AUTH_001)은 앞의 JwtAuthGuard 가 먼저 막는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppClientGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const user = context.switchToHttp().getRequest<Request>().user as
|
||||
| AuthenticatedUser
|
||||
| undefined;
|
||||
if (!user) {
|
||||
// JwtAuthGuard 없이 걸었다 — 조용히 통과하지 않게
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'app-client-guard-without-jwt' },
|
||||
});
|
||||
}
|
||||
if (user.client !== 'app' || !user.sessionId) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'app-client-required' },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -8,20 +10,34 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
export const JWT_STRATEGY = 'jwt';
|
||||
|
||||
/**
|
||||
* 액세스 토큰 쿠키로 보호하는 가드.
|
||||
* 액세스 토큰으로 보호하는 가드 — 웹은 HttpOnly 쿠키, 앱(모바일)은 `Authorization: Bearer` (계약 1절 "앱 로그인").
|
||||
* 앱 토큰(표시 cli: app)은 Bearer 로만, 웹 토큰은 쿠키로만 받는다 — 서로 바꿔 쓰면 AUTH_001 (modules/auth/jwt.strategy.ts).
|
||||
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다)
|
||||
*
|
||||
* 사용 예:
|
||||
* 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 —
|
||||
* 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 —
|
||||
* 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽).
|
||||
*
|
||||
* 사용 예 (Swagger 에는 @ApiCookieAuth() 만 적는다 — 앱의 Bearer 는 main.ts 가 "또는"으로 붙인다):
|
||||
* @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 +47,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,136 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
|
||||
import { requestIdMiddleware } from './request-id.middleware';
|
||||
import { requestTargetMiddleware } from './request-target.middleware';
|
||||
|
||||
/** 원문 바이트 그대로 요청을 보내고 응답(상태 · 머리글 · 본문)을 읽는다 — supertest 는 경로를 고쳐 보낸다(`\` → `/` 등) */
|
||||
function sendRaw(
|
||||
server: Server,
|
||||
head: Buffer,
|
||||
): Promise<{ status: number; headers: string; body: string }> {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
const chunks: Buffer[] = [];
|
||||
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
socket.on('error', reject);
|
||||
socket.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const [headers, ...rest] = text.split('\r\n\r\n');
|
||||
resolve({
|
||||
status: Number(headers.split(' ')[1]),
|
||||
headers: headers.toLowerCase(),
|
||||
body: rest.join('\r\n\r\n'),
|
||||
});
|
||||
});
|
||||
socket.write(head);
|
||||
});
|
||||
}
|
||||
|
||||
const requestLine = (method: string, target: string) =>
|
||||
Buffer.from(
|
||||
`${method} ${target} HTTP/1.1\r\nHost: portal.test\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
||||
'latin1',
|
||||
);
|
||||
|
||||
describe('요청 대상 원문 검사 미들웨어 (라우팅 전에 400 VAL_001)', () => {
|
||||
let server: Server;
|
||||
const reached: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.set('case sensitive routing', true);
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(requestTargetMiddleware);
|
||||
app.get('/api/health', (_req, res) => {
|
||||
reached.push('health');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post('/api/internal/mail-engine-events', (_req, res) => {
|
||||
reached.push('webhook');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.get('/api/search', (req, res) => {
|
||||
reached.push('search');
|
||||
res.json({ q: req.query.q });
|
||||
});
|
||||
server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
reached.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
['POST', '/api/internal\\mail-engine-events#x'],
|
||||
['GET', '/api/health\\#a'],
|
||||
['GET', '/api/health#a'],
|
||||
['GET', 'http://portal.test/api/internal\\mail-engine-events'],
|
||||
])(
|
||||
'%s %s — 처리기에 닿지 않고 표준 실패 형식의 400 VAL_001 (traceId · 연결 닫기)',
|
||||
async (method, target) => {
|
||||
const res = await sendRaw(server, requestLine(method, target));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers).toContain('connection: close');
|
||||
const body = JSON.parse(res.body) as {
|
||||
success: boolean;
|
||||
error: { code: string; traceId?: string };
|
||||
};
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error.code).toBe('VAL_001');
|
||||
expect(body.error.traceId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(res.headers).toContain(`x-request-id: ${body.error.traceId}`);
|
||||
expect(reached).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('정상 경로 · 퍼센트 인코딩한 한글은 그대로 지나간다', async () => {
|
||||
const health = await sendRaw(server, requestLine('GET', '/api/health'));
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const search = await sendRaw(
|
||||
server,
|
||||
requestLine('GET', '/api/search?q=%ED%95%9C%EA%B8%80'),
|
||||
);
|
||||
expect(search.status).toBe(200);
|
||||
expect(JSON.parse(search.body)).toEqual({ q: '한글' });
|
||||
expect(reached).toEqual(['health', 'search']);
|
||||
});
|
||||
|
||||
it('0xA0 · 제어 문자가 든 대상도 막는다 — 지금 Node 는 HTTP 파서가 먼저 거절하지만 파서가 느슨해져도 여기서 막는다', () => {
|
||||
for (const target of ['/api/a b', '/api/a\u0000b', '/api/a\u007fb']) {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const setHeader = jest.fn();
|
||||
const next = jest.fn();
|
||||
requestTargetMiddleware(
|
||||
{
|
||||
originalUrl: target,
|
||||
url: target,
|
||||
header: () => 'trace-1',
|
||||
} as unknown as Request,
|
||||
{ status, json, setHeader } as unknown as Response,
|
||||
next,
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: expect.any(String) as unknown,
|
||||
traceId: 'trace-1',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { hasForbiddenRequestTargetChar } from '@/shared/utils/request-target';
|
||||
import { REQUEST_ID_HEADER } from './request-id.middleware';
|
||||
|
||||
/**
|
||||
* 요청 대상 원문 검사 미들웨어 (express) — `configureApp()` 이 요청 추적 ID 바로 뒤, 라우팅 · 본문 파서 앞에 붙인다.
|
||||
* 원문 대상(`req.originalUrl` · `req.url`)에 날 `\` · `#` · 제어 문자 · 0x80 이상 글자가 있으면 표준 실패 형식의 400 VAL_001 로 끝낸다
|
||||
* (규칙과 까닭은 shared/utils/request-target.ts). 앞단 nginx 도 server 단계에서 400 으로 막는다(release-ops — 두 겹).
|
||||
* 본문을 받지 않고 끝내므로 연결을 닫는다. 로그를 남기지 않는다(두드려 보는 요청 — 대상 원문에 로그를 흐트러뜨리는 글자가 들 수 있다)
|
||||
*/
|
||||
export function requestTargetMiddleware(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const original = req.originalUrl ?? req.url;
|
||||
if (
|
||||
!hasForbiddenRequestTargetChar(original) &&
|
||||
(req.url === original || !hasForbiddenRequestTargetChar(req.url))
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.BAD_REQUEST).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VAL_001',
|
||||
message: getErrorDefinition('VAL_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
+30
-8
@@ -9,10 +9,11 @@ import type {
|
||||
Response as ExpressResponse,
|
||||
} from 'express';
|
||||
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { configureApp, createHttpAdapter } from '@/app.setup';
|
||||
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import { addAlternativeSecurity } from '@/shared/utils/openapi-security';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
@@ -21,12 +22,21 @@ import {
|
||||
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
/** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */
|
||||
const SWAGGER_COOKIE_AUTH = 'cookie';
|
||||
const SWAGGER_BEARER_AUTH = 'bearer';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
});
|
||||
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
AppModule,
|
||||
createHttpAdapter(),
|
||||
{
|
||||
logger: WinstonModule.createLogger(winstonConfig),
|
||||
// 본문 파서는 configureApp 이 직접 등록한다 (JSON 만, 크기 한도 지정)
|
||||
bodyParser: false,
|
||||
},
|
||||
);
|
||||
|
||||
// 기본 주소('/') 접속 시 상태 확인용 텍스트 응답 (컨테이너 healthcheck 가 읽는다)
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
@@ -67,14 +77,26 @@ async function bootstrap() {
|
||||
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
||||
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
||||
|
||||
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
|
||||
// 4. Swagger API 문서 설정 — 인증은 웹은 HttpOnly 쿠키(access_token), 앱은 Authorization: Bearer (앱 로그인이 준 토큰만 — 계약 1절 "앱 로그인").
|
||||
// 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드(JwtAuthGuard)가 두 길을 다 받으므로 Bearer 는 여기서 "또는"으로 한 번에 붙인다
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE)
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, SWAGGER_COOKIE_AUTH)
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'앱(모바일) 전용 — POST /api/auth/app/login · refresh 가 준 액세스 토큰만 받는다(웹 토큰은 401). 웹은 쿠키를 쓴다',
|
||||
},
|
||||
SWAGGER_BEARER_AUTH,
|
||||
)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
addAlternativeSecurity(document, SWAGGER_COOKIE_AUTH, SWAGGER_BEARER_AUTH);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 표 (docs/mail/api-contract.md 4절 "대용량 첨부" — 11단계).
|
||||
* - stored_files: 파일(올리기 id = 파일 id) — 주인 · 이름 · 선언한 크기 · 상태 · 검사 결과 · 보관 끝(expires_at). 바이트는 파일 볼륨(/app/files)
|
||||
* 부분 인덱스: 살아 있는 파일의 기간(정리) · 검사를 기다리는 파일 · 지운 파일(기록 정리)
|
||||
* - stored_file_chunks: 받은 조각(번호 · 크기 · SHA-256) — 파일을 지우면 함께 지워진다
|
||||
* - share_links: 공유 링크(메일 한 통) — 토큰은 SHA-256 만(유일 인덱스), 보낸 사람 · 보낸 메일 · 끝 · 끊음 · 취소
|
||||
* - share_link_files: 링크의 파일 — 보낸 차례 · 그때의 이름 · 크기 · 받은 수. 링크를 지우면 함께 지워진다(파일 표와는 묶지 않는다)
|
||||
* 새 표만 만든다 — 기존 표(auth_sessions · mail_signatures · mail_signature_images · mail_directory · mail_settings)는 건드리지 않는다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/*.entity.ts · modules/file-sharing/*.entity.ts)
|
||||
*/
|
||||
export class AddLargeFileSharing1793348306562 implements MigrationInterface {
|
||||
name = 'AddLargeFileSharing1793348306562';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "share_links" ("id" uuid NOT NULL, "token_hash" bytea NOT NULL, "owner_email" character varying(254) NOT NULL, "sender_name" character varying(200), "purpose" character varying(32) NOT NULL, "message_id" character varying(255), "sent_email_id" character varying(255), "sent_at" TIMESTAMP WITH TIME ZONE NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "revoked_by" character varying(32), "canceled_at" TIMESTAMP WITH TIME ZONE, "cancel_reason" character varying(32), CONSTRAINT "PK_70320b79ecd8acab96419fcdd6d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_share_links_token_hash" ON "share_links" ("token_hash") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_message" ON "share_links" ("owner_email", "message_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_sent_email" ON "share_links" ("owner_email", "sent_email_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_links_owner_sent" ON "share_links" ("owner_email", "sent_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "share_link_files" ("link_id" uuid NOT NULL, "file_id" uuid NOT NULL, "position" smallint NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "download_count" integer NOT NULL DEFAULT '0', "last_downloaded_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_2c813a6dd8adba8c8902c8314b4" PRIMARY KEY ("link_id", "file_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_share_link_files_file" ON "share_link_files" ("file_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "stored_files" ("id" uuid NOT NULL, "owner_email" character varying(254) NOT NULL, "purpose" character varying(16) NOT NULL, "name" character varying(255) NOT NULL, "size" bigint NOT NULL, "chunk_size" integer NOT NULL, "chunk_count" integer NOT NULL, "last_modified" bigint, "state" character varying(16) NOT NULL, "removed_reason" character varying(16), "first_chunk_sha256" character varying(44), "scan_status" character varying(16), "scan_signature" character varying(255), "scan_attempts" integer NOT NULL DEFAULT '0', "scan_next_at" TIMESTAMP WITH TIME ZONE, "scanned_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_chunk_at" TIMESTAMP WITH TIME ZONE, "completed_at" TIMESTAMP WITH TIME ZONE, "draft_attached_at" TIMESTAMP WITH TIME ZONE, "linked_at" TIMESTAMP WITH TIME ZONE, "linked_until" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "removed_at" TIMESTAMP WITH TIME ZONE, "bytes_deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_5d5be862bf53851c1794b4adf4e" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_removed" ON "stored_files" ("removed_at") WHERE "state" = 'removed'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_scan_pending" ON "stored_files" ("scan_next_at") WHERE "scan_status" = 'pending'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_alive_expires" ON "stored_files" ("expires_at") WHERE "state" <> 'removed'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_stored_files_owner_created" ON "stored_files" ("owner_email", "created_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "stored_file_chunks" ("file_id" uuid NOT NULL, "chunk_index" integer NOT NULL, "size" integer NOT NULL, "sha256" bytea NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_ea6c24c7b1bd69e0a10dd48d2b3" PRIMARY KEY ("file_id", "chunk_index"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "share_link_files" ADD CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7" FOREIGN KEY ("link_id") REFERENCES "share_links"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_file_chunks" ADD CONSTRAINT "FK_37be6c847f971f1b9536e7dd076" FOREIGN KEY ("file_id") REFERENCES "stored_files"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_file_chunks" DROP CONSTRAINT "FK_37be6c847f971f1b9536e7dd076"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "share_link_files" DROP CONSTRAINT "FK_def8bbe7f42c897e71e8c2472c7"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "stored_file_chunks"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_owner_created"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_alive_expires"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_stored_files_scan_pending"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_stored_files_removed"`);
|
||||
await queryRunner.query(`DROP TABLE "stored_files"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_share_link_files_file"`);
|
||||
await queryRunner.query(`DROP TABLE "share_link_files"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_share_links_owner_sent"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_share_links_owner_sent_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_share_links_owner_message"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_share_links_token_hash"`);
|
||||
await queryRunner.query(`DROP TABLE "share_links"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 대용량 첨부 — 끝내기를 시작한 때 (11단계 리뷰 L4, 2026-10-30).
|
||||
* - stored_files.completion_started_at: 끝내는 중(completing)일 때만 값이 있다. 정리 작업은 이것이 오래된 끝내기만 멈춘 것으로 되살린다
|
||||
* (마지막 조각의 때로 고르면 방금 시작한 끝내기를 되돌릴 수 있었다)
|
||||
* 빈 값을 허용하는 칸 하나를 더한다 — 기본값이 없어 표를 다시 쓰지 않는다(곧 끝난다). 기존 행은 null 이고, 정리 작업은 그때 마지막 조각 · 만든 때로 본다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/file-storage/stored-file.entity.ts)
|
||||
*/
|
||||
|
||||
export class AddFileCompletionStartedAt1793358223047 implements MigrationInterface {
|
||||
name = 'AddFileCompletionStartedAt1793358223047';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_files" ADD "completion_started_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "stored_files" DROP COLUMN "completion_started_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 계정 보안 표 (docs/mail/api-contract.md 5절 "계정 보안" — 12단계).
|
||||
* - login_events: 로그인 기록(웹메일 · 메일 프로그램) — 90일 보존. 직원 · 일어난 때 · 차례 번호 인덱스(커서 페이지),
|
||||
* 일어난 때 인덱스(정리), 엔진 이벤트의 겹침 거르기 키 유일 인덱스(웹메일 기록은 null — 여럿이어도 된다)
|
||||
* - app_password_usages: 앱 비밀번호의 서버 기록(이름 · 엔진의 만든 때 · 마지막 사용) — 직원 · 앱 비밀번호 id 마다 한 줄
|
||||
* - two_factor_settings: 웹메일에서 2단계 인증을 켠 때 — 직원 주소마다 한 줄 (비밀은 두지 않는다)
|
||||
* - auth_sessions: 로그인 · 갱신한 클라이언트의 IP · User-Agent 칸(12단계 전의 세션은 null)과 직원 주소 인덱스(로그인된 브라우저 목록 · 다른 곳 모두 끊기)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/account-security/*.entity.ts · modules/auth/auth-session.entity.ts)
|
||||
*/
|
||||
export class AddAccountSecurity1793415011070 implements MigrationInterface {
|
||||
name = 'AddAccountSecurity1793415011070';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "login_events" ("id" BIGSERIAL NOT NULL, "user_email" character varying(254) NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "method" character varying(32) NOT NULL, "is_success" boolean NOT NULL, "is_two_factor" boolean NOT NULL, "failure_reason" character varying(32), "ip" character varying(64), "user_agent" character varying(512), "app_password_id" character varying(16), "app_password_name" character varying(40), "dedupe_key" character(43), CONSTRAINT "PK_5cd6102474db62bc181590b9902" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_occurred_at" ON "login_events" ("occurred_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_login_events_dedupe_key" ON "login_events" ("dedupe_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_user_occurred" ON "login_events" ("user_email", "occurred_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_password_usages" ("user_email" character varying(254) NOT NULL, "app_password_id" character varying(16) NOT NULL, "name" character varying(40) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_used_at" TIMESTAMP WITH TIME ZONE, "last_used_method" character varying(8), "last_used_ip" character varying(64), CONSTRAINT "PK_6db68c01bf629755c5eeaf95322" PRIMARY KEY ("user_email", "app_password_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "two_factor_settings" ("user_email" character varying(254) NOT NULL, "enabled_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e1f907880f3d367cca2717934b6" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "ip" character varying(64)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "user_agent" character varying(512)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_user_email" ON "auth_sessions" ("user_email") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "user_agent"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "ip"`);
|
||||
await queryRunner.query(`DROP TABLE "two_factor_settings"`);
|
||||
await queryRunner.query(`DROP TABLE "app_password_usages"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_user_occurred"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_login_events_dedupe_key"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "login_events"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 표 (14단계 — 자체 검색 색인 · 대화 묶기의 바탕, 설계 A).
|
||||
* - mail_search_messages: 직원 주소 + 엔진 메일 id 마다 한 줄 — 메일함 · 키워드 · 받은 시각 · 답장 관계 · 원문 글(NFC) · 접기 글(fold_*) ·
|
||||
* 글자 두 개 묶음(grams) · 대화 키. 색인: PK, (직원, 받은 시각, id) · (직원, Message-ID) · (직원, 대화 키),
|
||||
* GIN 셋(grams · mailbox_ids · refs — TypeORM 이 GIN 을 적지 못해 엔티티에는 synchronize: false 로 이름만 있다)
|
||||
* - mail_search_states: 직원마다 한 줄 — 상태(building · ready) · 엔진 Email state · 채우기 자리 · 멈춘 까닭 · 실패 수
|
||||
* - mail_settings.group_by_conversation: 목록을 대화로 묶어 보기(기본 끔 — API 는 14단계 3차)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 표를 새로 만들고 칸 하나를 더하기만 한다.
|
||||
* (자동 생성: npm run migration:generate — GIN 색인 셋만 손으로 더했다. 엔티티는 modules/mail-search/*.entity.ts ·
|
||||
* modules/mail-user-settings/mail-user-settings.entity.ts)
|
||||
*/
|
||||
export class AddMailSearchIndex1793873715423 implements MigrationInterface {
|
||||
name = 'AddMailSearchIndex1793873715423';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_search_messages" ("user_email" character varying(254) NOT NULL, "email_id" character varying(64) NOT NULL, "mailbox_ids" text array NOT NULL, "keywords" text array NOT NULL, "received_at" TIMESTAMP WITH TIME ZONE NOT NULL, "size" integer NOT NULL, "message_id" text, "refs" text array NOT NULL, "subject" text NOT NULL, "subject_base" text NOT NULL, "from_text" text NOT NULL, "to_text" text NOT NULL, "cc_text" text NOT NULL, "attachment_names" text NOT NULL, "has_attachment" boolean NOT NULL, "preview" text NOT NULL, "body_text" text NOT NULL, "fold_from" text NOT NULL, "fold_to" text NOT NULL, "fold_subject" text NOT NULL, "fold_attachment" text NOT NULL, "fold_body" text NOT NULL, "grams" text array NOT NULL, "thread_key" character varying(64) NOT NULL, "engine_thread_id" character varying(64), "indexed_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_bdeada2a9bbda7dece469d81b6f" PRIMARY KEY ("user_email", "email_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_thread_key" ON "mail_search_messages" ("user_email", "thread_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_message_id" ON "mail_search_messages" ("user_email", "message_id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_received" ON "mail_search_messages" ("user_email", "received_at", "email_id") `,
|
||||
);
|
||||
// GIN — 찾는 말의 글자 두 개 묶음이 모두 든 줄(grams @> …) · 메일함 범위(mailbox_ids && …) · 늦게 온 원본이 자식을 찾기(refs @> …)
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_refs" ON "mail_search_messages" USING GIN ("refs")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "mail_search_states" ("user_email" character varying(254) NOT NULL, "status" character varying(32) NOT NULL, "email_state" text, "build_anchor" character varying(64), "build_done" integer NOT NULL DEFAULT '0', "build_total" integer, "build_started_at" TIMESTAMP WITH TIME ZONE, "ready_at" TIMESTAMP WITH TIME ZONE, "last_synced_at" TIMESTAMP WITH TIME ZONE, "paused_reason" character varying(32), "failure_count" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e8aa94c380c49ff05919e5177ab" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_states_status" ON "mail_search_states" ("status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_settings" ADD "group_by_conversation" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_settings" DROP COLUMN "group_by_conversation"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_states_status"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_search_states"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_refs"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_received"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_message_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_thread_key"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "mail_search_messages"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 2차 (14단계 설계 G — 1만 통 측정 뒤).
|
||||
* - 글자 두 개 묶음 GIN 을 직원 주소와 함께로 바꾼다(btree_gin). 주소 칸은 **`lower("user_email")` 식**으로 넣는다 —
|
||||
* 후보를 찾는 쿼리가 같은 식으로만 직원을 거르면 이 색인 말고 쓸 수 있는 색인이 없다. 주소 그대로(`"user_email"`)면 계획기가 흔한 묶음에서
|
||||
* 주소의 btree 로 그 직원의 줄을 모두 읽고 묶음을 줄마다 풀어(TOAST) 보는 길을 골랐다(1만 통에 260~700ms — 이 색인이면 10ms 안팎, 2026-11-05 측정).
|
||||
* 표의 주소는 늘 소문자다(서비스가 맞춘다) — 식은 값을 바꾸지 않는다.
|
||||
* `btree_gin` 은 PostgreSQL 13 부터 trusted 확장이라 DB 주인 계정이 만든다(이미 있으면 그대로)
|
||||
* - 상태 줄에 색인 판(index_format)을 더한다 — 지금까지 만든 줄은 1(이 칸의 처음 값). 서버가 판이 다른 줄을 building 으로 돌려 다시 채운다
|
||||
* 기존 표의 칸 · 행은 지우지 않는다. 내릴 때 확장은 남긴다(다른 곳이 쓸 수 있다).
|
||||
* (자동 생성: npm run migration:generate — GIN 두 개 · 확장은 손으로 적었다. 엔티티는 modules/mail-search/*.entity.ts)
|
||||
*/
|
||||
export class SearchIndexUserGrams1793882273224 implements MigrationInterface {
|
||||
name = 'SearchIndexUserGrams1793882273224';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS btree_gin`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_states" ADD "index_format" smallint NOT NULL DEFAULT '1'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_states" DROP COLUMN "index_format"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_grams" ON "mail_search_messages" USING GIN ("grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 3차 리뷰 L3 (14단계 — 대화로 묶은 목록의 참여자).
|
||||
* - 색인 줄에 첫 보낸 사람의 주소(`from_email` — 254) · 이름(`from_name` — 100 코드 포인트)을 따로 둔다. 참여자는 이 칸만 읽는다 —
|
||||
* 보낸 사람 글(`from_text` — "이름 <주소>")을 다시 해석하면 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람이 그 주소로 보이고, 400자에서 잘린 줄은 빠졌다
|
||||
* - 둘 다 null 을 받는다(주소가 없는 보낸 사람 · 지금 있는 줄). 지금 있는 줄은 색인 판 4(INDEX_FORMAT)로 다시 채우며 채운다
|
||||
* 기존 칸 · 행은 지우지 않는다. (자동 생성: npm run migration:generate — 엔티티는 modules/mail-search/mail-search-message.entity.ts)
|
||||
*/
|
||||
export class AddMailSearchSender1793898213073 implements MigrationInterface {
|
||||
name = 'AddMailSearchSender1793898213073';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" ADD "from_email" character varying(254)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" ADD "from_name" character varying(100)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_name"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_search_messages" DROP COLUMN "from_email"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 앱 전용 토큰 경로 (docs/mail/api-contract.md 1절 "앱 로그인" — 앱 1단계).
|
||||
* - auth_sessions.client: 세션을 연 클라이언트 — web(브라우저 · 쿠키) · app(모바일 앱 · Bearer). 기존 세션은 기본값 'web'.
|
||||
* DB 기본값을 두어 이 칸을 모르는 코드(다른 브랜치)가 넣는 행도 웹 세션으로 들어간다
|
||||
* 로그인 기록의 방식 `app` 은 칸을 바꾸지 않는다 — login_events.method 는 제약 없는 varchar(32) 다.
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하기만 한다.
|
||||
* (엔티티: modules/auth/auth-session.entity.ts — migration:generate 의 결과에서 이 칸의 두 문장만 옮겼다.
|
||||
* 개발 DB 에 다른 브랜치의 칸(mail_settings.group_by_conversation)이 있어 generate 가 그 칸을 지우는 문장도 냈다 — 넣지 않는다)
|
||||
*/
|
||||
export class AddAppSessionClient1793933074677 implements MigrationInterface {
|
||||
name = 'AddAppSessionClient1793933074677';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "client" character varying(16) NOT NULL DEFAULT 'web'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "client"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 메일 검색 색인 — 메일함 범위 찾기 (14단계 실사용 시험, 2026-11-06).
|
||||
* - 글자 두 개 묶음 GIN 에 메일함 칸을 더한다: GIN 하나 (lower("user_email"), "grams", "mailbox_ids") 가 직원 · 묶음 · 메일함을 한 번에 맞춘다.
|
||||
* 주소 · 묶음 GIN(`IDX_mail_search_messages_user_grams`)과 메일함만 담은 GIN(`IDX_mail_search_messages_mailbox_ids`)은 지운다.
|
||||
* - 까닭: 메일함 범위의 후보 쿼리(`"mailbox_ids" @> $n` — search-sql.ts 의 candidatesSql)에서 계획기가 메일함 GIN 만으로 그 메일함의 줄을 모두 읽고
|
||||
* 묶음 배열(TOAST)을 줄마다 풀어 보는 길을 골랐다. 메일함 id 는 계정마다 같은 꼴(a · b …)이라 그 posting 은 회사 전체이고, 묶음 확인이
|
||||
* 주소 확인보다 먼저 돌아 다른 직원의 줄까지 풀었다 — 1만 통 메일함 안 1.1초, 그 메일함 id 가 남의 큰 메일함과 같은 직원은 빈 메일함에도 1.6초.
|
||||
* 계획기는 TOAST 를 푸는 값을 모르고, 묶음 GIN 의 대기 목록(fastupdate)이 쌓이면 그 GIN 을 더 비싸게 보아 이 길로 넘어간다 —
|
||||
* 그래서 고를 길을 하나만 남긴다(이 색인 말고 `lower("user_email")` · 묶음 · 메일함 조건을 받는 색인이 없다. 메일함만 담은 색인을 다시 만들지 않는다).
|
||||
* - 같은 표의 흉내(10명 · 10만 줄 — 메일함 id 가 겹친다)에서 큰 메일함 29ms · 작은 메일함 1~7ms · 전체 13ms
|
||||
* (전에는 큰 메일함 43~125ms · 작은 메일함 5~88ms — 메일함 GIN 과 함께 쓸 때와 쓰지 않을 때). 그 표의 몫(직원 주소 · 메일함 칸)은 묶음보다 훨씬 작다.
|
||||
* 쓰기 비용은 지금과 같다 — 메일함이 바뀐 줄은 지금도 두 GIN 에 새로 들어간다(색인된 칸이라 HOT 이 아니다). 표의 칸 · 행은 그대로다.
|
||||
* btree_gin 확장은 SearchIndexUserGrams 가 만들었다. 내릴 때는 두 색인을 다시 만든다.
|
||||
* (손으로 적었다 — TypeORM 이 GIN 을 적지 못한다. 엔티티는 modules/mail-search/mail-search-message.entity.ts 에 synchronize: false 로 이름만)
|
||||
*/
|
||||
export class SearchIndexUserGramsMailboxes1793979304483 implements MigrationInterface {
|
||||
name = 'SearchIndexUserGramsMailboxes1793979304483';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams_mailboxes" ON "mail_search_messages" USING GIN (lower("user_email"), "grams", "mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_mailbox_ids"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_mailbox_ids" ON "mail_search_messages" USING GIN ("mailbox_ids")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_search_messages_user_grams" ON "mail_search_messages" USING GIN (lower("user_email"), "grams")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_search_messages_user_grams_mailboxes"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 표 (docs/mail/api-contract.md 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계).
|
||||
* - push_registrations: 앱 세션(sid)마다 한 줄 — 휴대폰 주소(FID) · 플랫폼 · 앱 판 · N 을 세는 시작(seen_at) · 마지막으로 보낸 때 · 만든 때 · 바꾼 때.
|
||||
* 직원 주소 인덱스(직원마다 읽기 · 상한 · 퇴사 마무리), FID 유일 인덱스(같은 휴대폰은 한 줄에만). 세션 표와 외래 키로 묶지 않는다
|
||||
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/push/push-registration.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
|
||||
*/
|
||||
export class AddPushRegistrations1794057659283 implements MigrationInterface {
|
||||
name = 'AddPushRegistrations1794057659283';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "push_registrations" ("session_id" uuid NOT NULL, "user_email" character varying(254) NOT NULL, "fid" character varying(64) NOT NULL, "platform" character varying(16) NOT NULL, "app_version" character varying(16) NOT NULL, "seen_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_pushed_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_6f35810b223ea06c6d65e6f4219" PRIMARY KEY ("session_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_push_registrations_user_email" ON "push_registrations" ("user_email") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_push_registrations_fid" ON "push_registrations" ("fid") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_push_registrations_fid"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_push_registrations_user_email"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "push_registrations"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 판 표 (docs/mail/api-contract.md 1절 "휴대폰 앱 받기" — 사내 배포, 2026-11-08).
|
||||
* - app_releases: 운영 도구 「휴대폰 앱 올리기」가 올린 판마다 한 줄 — 플랫폼 · 판 · 크기 · SHA-256 · 디스크 이름(file_key UUID) · 올린 이(운영자 이름) · 올린 때.
|
||||
* file_key 유일 인덱스(디스크 이름은 한 줄에만), (platform, created_at) 인덱스(최신 = 그 플랫폼에서 가장 늦게 올린 줄)
|
||||
* 기존 표의 칸 · 행은 건드리지 않는다 — 표를 새로 만들기만 한다.
|
||||
* (엔티티: modules/app-releases/app-release.entity.ts — migration:generate 의 결과(이 표의 세 문장뿐이었다)를 그대로 옮겼다)
|
||||
*/
|
||||
export class AddAppReleases1794140434961 implements MigrationInterface {
|
||||
name = 'AddAppReleases1794140434961';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_releases" ("id" uuid NOT NULL, "platform" character varying(16) NOT NULL, "version" character varying(16) NOT NULL, "size_bytes" bigint NOT NULL, "sha256" character(64) NOT NULL, "file_key" uuid NOT NULL, "uploaded_by" character varying(100) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e5b28b6fe128172639b75251cd1" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_app_releases_file_key" ON "app_releases" ("file_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_app_releases_platform_created_at" ON "app_releases" ("platform", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_app_releases_platform_created_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_app_releases_file_key"`);
|
||||
await queryRunner.query(`DROP TABLE "app_releases"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 여러 서명 (docs/mail/api-contract.md 2절 "서명" — 16단계).
|
||||
* mail_signatures 를 "한 사람에 한 줄(열쇠 = 주소)"에서 "서명마다 한 줄(열쇠 = id)"로 바꾼다 — 표를 새로 만들지 않고 고쳐 쓴다
|
||||
* (퇴사 마무리는 그대로 주소로 이 표의 줄을 지운다 — modules/ops/ops-data.service.ts).
|
||||
* - 지금 서명은 "기본 서명"이라는 이름의 서명 하나가 되고, 만든 시각은 저장한 시각이다. **내용이 비어 있던 서명(서식 · 텍스트가 모두 없거나 공백뿐)은 옮기지 않는다**(보드 결정)
|
||||
* - 두 체크(is_for_new · is_for_reply — 그 서명을 새 메일 · 답장과 전달에 넣는다)는 「메일에 넣을 서명」 둘의 표시가 된다 — 켠 곳의 기본이 그 서명, 끈 곳은 넣지 않음.
|
||||
* 칸 이름만 바꿔 값을 그대로 둔다(migration:generate 는 칸을 지우고 새로 만들어 값을 잃는다). 처음 값은 false(새로 만든 서명은 기본이 아니다)
|
||||
* - 한 사람에 기본 하나씩 — 부분 유일 인덱스 둘. 목록(만든 차례)은 (user_email, created_at) 인덱스
|
||||
* - 서명 그림 표(mail_signature_images)는 그대로 — 그림은 모든 서명이 함께 쓴다
|
||||
* 되돌리기(down): 한 사람에 한 줄만 남긴다 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 하나, **나머지 서명은 지운다**.
|
||||
* 남긴 서명의 두 표시가 옛 두 체크가 된다. 옛 서버는 서명을 다음에 저장할 때 그 서명이 가리키지 않는 그림을 지운다.
|
||||
* (엔티티: modules/mail-signature/mail-signature.entity.ts — migration:generate 의 결과에서 이름(PK · 인덱스)은 그대로 두고 값을 지키게 고쳤다)
|
||||
*/
|
||||
export class MultipleMailSignatures1794269810995 implements MigrationInterface {
|
||||
name = 'MultipleMailSignatures1794269810995';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 내용이 비어 있던 서명은 옮기지 않는다 — 서식 · 텍스트 모두 없거나 공백뿐 (서비스는 빈 서식을 null 로 저장해 왔다)
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" WHERE ("body_html" IS NULL OR "body_html" !~ '\\S') AND ("body_text" IS NULL OR "body_text" !~ '\\S')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "id" uuid NOT NULL DEFAULT gen_random_uuid()`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "id" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "name" character varying(64) NOT NULL DEFAULT '기본 서명'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "name" DROP DEFAULT`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD "created_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "mail_signatures" SET "created_at" = "updated_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "created_at" SET NOT NULL`,
|
||||
);
|
||||
// 두 체크 → 「메일에 넣을 서명」 둘의 표시 (값은 그대로)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_new" TO "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_new_message_default" SET DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_for_reply" TO "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_reply_default" SET DEFAULT false`,
|
||||
);
|
||||
// 열쇠 — 주소 → 서명 id
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b" PRIMARY KEY ("id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_reply_default" ON "mail_signatures" ("user_email") WHERE "is_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_mail_signatures_new_message_default" ON "mail_signatures" ("user_email") WHERE "is_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_mail_signatures_user_email_created_at" ON "mail_signatures" ("user_email", "created_at") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 한 사람에 한 줄만 — 「새 메일」 기본 → 「답장 · 전달」 기본 → 먼저 만든 서명 차례로 남기고 나머지는 지운다
|
||||
await queryRunner.query(
|
||||
`DELETE FROM "mail_signatures" AS "s" USING (SELECT "id", row_number() OVER (PARTITION BY "user_email" ORDER BY "is_new_message_default" DESC, "is_reply_default" DESC, "created_at" ASC, "id" ASC) AS "rank" FROM "mail_signatures") AS "ranked" WHERE "s"."id" = "ranked"."id" AND "ranked"."rank" > 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_mail_signatures_user_email_created_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_new_message_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."UQ_mail_signatures_reply_default"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP CONSTRAINT "PK_d210af8f68aec58b45e5f2ea02b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ADD CONSTRAINT "PK_4846a94ce96c5f4fe400f449a7f" PRIMARY KEY ("user_email")`,
|
||||
);
|
||||
// 남긴 서명의 표시 → 옛 두 체크 (처음 값은 옛 그대로 true)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_reply_default" TO "is_for_reply"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_reply" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" RENAME COLUMN "is_new_message_default" TO "is_for_new"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" ALTER COLUMN "is_for_new" SET DEFAULT true`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "mail_signatures" DROP COLUMN "created_at"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "name"`);
|
||||
await queryRunner.query(`ALTER TABLE "mail_signatures" DROP COLUMN "id"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JsonShapeLimits } from '@/shared/utils/json-shape';
|
||||
|
||||
/**
|
||||
* 계정 보안(12단계 — 계약 5절 "계정 보안") 상수 — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅.
|
||||
* 로그인 · 코드 실패 한도는 같은 카운터를 쓰므로 modules/auth/auth.constants.ts 에 있다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 인증 앱에 보일 발급자 이름 — 앱에는 「ARA SYSTEM (주소)」로 저장된다 (계약 5절 "정한 것" 1).
|
||||
* 엔진(totp-rs from_url)이 이 이름이 든 otpauth 주소를 받고 그 주소로 코드를 확인하는 것을 개발 엔진에서 확인했다 (2026-10-31)
|
||||
*/
|
||||
export const TWO_FACTOR_ISSUER = 'ARA SYSTEM';
|
||||
/** 비밀 길이 — 160비트(base32 32자) */
|
||||
export const TWO_FACTOR_SECRET_BYTES = 20;
|
||||
/** 등록을 시작하고 켜기까지 — 10분 (계약 5절 "한도 · 규칙") */
|
||||
export const TWO_FACTOR_ENROLLMENT_TTL_MS = 10 * 60_000;
|
||||
/**
|
||||
* 같은 직원이 성공에 쓴 코드를 다시 받지 않는 시간 — 2분 (코드가 받아지는 90초보다 길게 — 로그인 · 설정 모두).
|
||||
* 엔진은 같은 코드를 다시 받는다(재사용을 막지 않는다 — 2026-10-31 확인)
|
||||
*/
|
||||
export const TWO_FACTOR_CODE_REUSE_MS = 2 * 60_000;
|
||||
|
||||
/** 앱 비밀번호 이름 — 1~40자 (앞뒤 공백을 지운 값, JS 문자열 길이) */
|
||||
export const APP_PASSWORD_NAME_MAX_LENGTH = 40;
|
||||
/**
|
||||
* 앱 비밀번호의 권한 — 메일 프로그램만(로그인 · IMAP · 메일 보내기). 엔진 기본(Inherit)이면 앱 비밀번호로 JMAP 을 쓰고
|
||||
* 앱 비밀번호를 또 만들 수 있다 — 그래서 늘 좁힌다 (계약 5절 "앱 비밀번호", 2026-10-31 확인)
|
||||
*/
|
||||
export const APP_PASSWORD_PERMISSIONS = {
|
||||
authenticate: true,
|
||||
imapAuthenticate: true,
|
||||
emailSend: true,
|
||||
} as const;
|
||||
|
||||
/** 로그인 기록 보존 — 90일 (날마다 정리) */
|
||||
export const LOGIN_EVENT_RETENTION_DAYS = 90;
|
||||
/**
|
||||
* 엔진 이벤트 기록을 한 문장에 넣는 행 수 — PostgreSQL 한 문장의 매개변수 상한(65,535)을 넘지 않게 나눈다.
|
||||
* 행마다 11칸이라 5,958행부터 넘는다(2026-10-31 리뷰) — 엔진은 실패한 묶음에 새 이벤트를 합쳐 다시 보내 묶음이 줄지 않는다
|
||||
*/
|
||||
export const LOGIN_EVENT_INSERT_CHUNK = 1_000;
|
||||
|
||||
/** 엔진 웹훅을 받는 경로 (글로벌 prefix 뒤) — 앞단 nginx 가 /api/internal/ 을 닫는다 (계약 5절 "엔진 웹훅 받기") */
|
||||
export const MAIL_ENGINE_EVENTS_ROUTE = 'internal/mail-engine-events';
|
||||
/** 이 경로만의 JSON 본문 한도 — 공격이 오면 엔진의 1초 묶음이 커진다 (계약 5절) */
|
||||
export const MAIL_ENGINE_EVENTS_JSON_LIMIT = '4mb';
|
||||
export const MAIL_ENGINE_EVENTS_SHAPE_LIMITS: JsonShapeLimits = {
|
||||
maxArrayLength: 10_000,
|
||||
maxValues: 200_000,
|
||||
maxDepth: 32,
|
||||
};
|
||||
/** 엔진이 본문에 붙이는 서명 머리글 — 본문 원문의 HMAC-SHA256, base64 */
|
||||
export const MAIL_ENGINE_SIGNATURE_HEADER = 'x-signature';
|
||||
/**
|
||||
* 웹훅 거절의 warn 을 같은 까닭마다 이 시간에 한 번만 남긴다 — 서명 키가 어긋나면 엔진이 1초마다 묶음을 보내고 다시 보내
|
||||
* 거절마다 남기면 하루 수만 줄이 된다 (2026-10-31 개발 — 서명 키 없이 뜬 백엔드에 1초마다). 그사이 거절한 수는 다음 줄에 붙인다
|
||||
*/
|
||||
export const MAIL_ENGINE_REJECTION_LOG_WINDOW_MS = 60_000;
|
||||
|
||||
/** 앱 네트워크 대역의 기본값 — compose 의 APP_NETWORK_SUBNET 기본값과 같다 (docker 스킬) */
|
||||
export const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24';
|
||||
|
||||
/** 웹훅 서명 키 — 영문 · 숫자 32자 이상 (Node 의 --env-file 이 `#` 부터 자른다 — 계약 5절 "환경 변수") */
|
||||
export const MAIL_WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
|
||||
@@ -0,0 +1,111 @@
|
||||
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,
|
||||
type AccountSecuritySettings,
|
||||
buildAccountSecuritySettings,
|
||||
} from './account-security.settings';
|
||||
import { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsController } from './app-passwords.controller';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsCleanup } from './login-events.cleanup';
|
||||
import { LoginEventsController } from './login-events.controller';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsController } from './mail-engine-events.controller';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { 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';
|
||||
|
||||
function createSettings(config: ConfigService): AccountSecuritySettings {
|
||||
const settings = buildAccountSecuritySettings((key) =>
|
||||
config.get<string>(key),
|
||||
);
|
||||
if (settings.webhookSecret === null) {
|
||||
// 비운영에서만 여기까지 온다 (운영은 env 검증이 기동을 막는다)
|
||||
new Logger('AccountSecurityModule').warn(
|
||||
'MAIL_WEBHOOK_SECRET 이 없어 메일 엔진 웹훅을 모두 401 로 거절합니다 — 메일 프로그램(IMAP · SMTP)의 로그인 기록이 남지 않습니다.',
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 보안 모듈 (12단계 — 계약 5절 "계정 보안") — 2단계 인증 · 앱 비밀번호 · 로그인 기록 · 엔진 웹훅 받기.
|
||||
* 엔진을 바꿀 때는 ACCOUNT_SECURITY_PROVIDER 의 구현(useClass)만 바꾼다 — 서비스 · 컨트롤러는 포트만 안다.
|
||||
* 지금 구현은 Stalwart(JMAP) — 메일과 같은 엔진 연결(MailEngineModule)을 쓰고, 어댑터는 `modules/mail/jmap/` 에 있다.
|
||||
*
|
||||
* 인증 모듈이 로그인 · 비밀번호 바꾸기에 쓰도록 코드 한도 · 잠금 · 로그인 기록 · 앱 비밀번호를 내보낸다.
|
||||
* 이 모듈은 인증 모듈을 가져오지 않는다(인증 → 계정 보안 방향만) — 함께 쓰는 실패 카운터는 LoginAttemptModule 하나가 내놓는다.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TwoFactorSettingEntity,
|
||||
AppPasswordUsageEntity,
|
||||
LoginEventEntity,
|
||||
]),
|
||||
MailEngineModule,
|
||||
// 지금 비밀번호 확인(verifyCredentials — 본인 Basic)
|
||||
MailModule,
|
||||
// 로그인 기록의 실패를 사내 목록으로 거른다
|
||||
ContactsModule,
|
||||
LoginAttemptModule,
|
||||
// 13단계 — 2단계 인증 필수 정책(서버 설정)과, 켜면 세션의 twoFactorSetup 제한 지우기
|
||||
SystemSettingsModule,
|
||||
AuthSessionModule,
|
||||
],
|
||||
controllers: [
|
||||
TwoFactorController,
|
||||
AppPasswordsController,
|
||||
LoginEventsController,
|
||||
MailEngineEventsController,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_SETTINGS,
|
||||
inject: [ConfigService],
|
||||
useFactory: createSettings,
|
||||
},
|
||||
{
|
||||
provide: ACCOUNT_SECURITY_PROVIDER,
|
||||
useClass: JmapAccountSecurityProvider,
|
||||
},
|
||||
TwoFactorService,
|
||||
TwoFactorEnrollments,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
TwoFactorPolicyService,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
LoginEventsCleanup,
|
||||
MailEngineEventsService,
|
||||
MailEngineEventsGuard,
|
||||
],
|
||||
exports: [
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
// 13단계 — 인증 모듈이 로그인 · 갱신에서 2단계 인증 필수 정책을 본다
|
||||
TwoFactorPolicyService,
|
||||
TwoFactorCodeGuard,
|
||||
TwoFactorLocks,
|
||||
AppPasswordsService,
|
||||
LoginEventsService,
|
||||
],
|
||||
})
|
||||
export class AccountSecurityModule {}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 계정 보안 엔진 포트 (계약 5절 — 2단계 인증 · 앱 비밀번호) — 서비스 계층은 이 인터페이스만 안다 (메일 엔진 포트 mail.provider.ts 와 같은 규칙).
|
||||
*
|
||||
* 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-account-security.provider.ts`(게이트웨이 대리 접근으로 x:AccountPassword · x:AppPassword).
|
||||
* 엔진을 바꿀 때는 이 인터페이스의 새 구현을 account-security.module.ts 에 연결한다.
|
||||
*
|
||||
* 규칙:
|
||||
* - 우리 도메인 타입만 받고 돌려준다 (엔진 타입 · id 형식을 밖으로 흘리지 않는다 — 앱 비밀번호 id 는 불투명 문자열)
|
||||
* - 실패는 BusinessException(에러 코드)으로 던진다 — 엔진 장애 MAIL_001 · 한도 REQ_001 · 계정을 쓸 수 없음 AUTH_001
|
||||
* - `user` 는 인증된 직원 주소다. 어댑터는 그 직원의 자격 증명에만 접근한다
|
||||
* - 비밀번호 · 코드 · 비밀(otpauth) · 앱 비밀번호 값 · 이름은 어떤 로그에도 남기지 않는다
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_PROVIDER = Symbol('ACCOUNT_SECURITY_PROVIDER');
|
||||
|
||||
/** 앱 비밀번호 하나 — 값은 만들 때만 (CreatedAppPassword) */
|
||||
export interface AppPasswordRecord {
|
||||
/** 엔진의 앱 비밀번호 id — 지운 번호를 엔진이 다시 쓴다 (createdAt 과 함께 봐야 같은 것이다) */
|
||||
readonly id: string;
|
||||
/** 이름 — 직원이 붙인 글 */
|
||||
readonly name: string;
|
||||
/** 만든 때 (ISO 8601 UTC) */
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값(password)은 이 한 번만 엔진이 준다 */
|
||||
export interface CreatedAppPassword extends AppPasswordRecord {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 켜기의 엔진 판단:
|
||||
* - enabled: 켰다
|
||||
* - refused: 엔진이 받지 않았다 — 지금 비밀번호가 맞지 않음 · 그사이 다른 곳에서 켜짐(지금 코드를 요구함)
|
||||
* - unknown: 결과를 알 수 없게 끝났다(시간 초과 · 연결 끊김 · 5xx) — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorEnableResult = 'enabled' | 'refused' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 끄기의 엔진 판단 — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - disabled: 껐다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진을 다시 읽어 맞춘다
|
||||
*/
|
||||
export type TwoFactorDisableResult = 'disabled' | 'code-rejected' | 'unknown';
|
||||
|
||||
/**
|
||||
* 2단계 인증 휴대폰 바꾸기의 엔진 판단 (16단계 — 켠 채 새 비밀로) — 비밀번호는 부르는 쪽이 먼저 확인한다:
|
||||
* - replaced: 바꿨다 — 옛 비밀은 바로 죽는다
|
||||
* - code-rejected: 엔진이 받지 않았다 — 지금 휴대폰의 코드 틀림 (엔진은 비밀번호 틀림과 코드 틀림을 가르지 않는다)
|
||||
* - unknown: 결과를 알 수 없게 끝났다 — 엔진은 otpUrl 을 가려 보여 주므로 다시 읽어도 가를 수 없다
|
||||
*/
|
||||
export type TwoFactorReplaceResult = 'replaced' | 'code-rejected' | 'unknown';
|
||||
|
||||
export interface AccountSecurityProvider {
|
||||
/** 엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다 (계약 5절 "2단계 인증") */
|
||||
isTwoFactorEnabled(user: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠다 — 지금 비밀번호와 otpauth 주소(서버가 만든 비밀). 엔진은 코드를 보지 않고 저장한다 —
|
||||
* 첫 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
enableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorEnableResult>;
|
||||
|
||||
/** 2단계 인증을 끈다 — 지금 비밀번호와 지금 코드 */
|
||||
disableTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
): Promise<TwoFactorDisableResult>;
|
||||
|
||||
/**
|
||||
* 2단계 인증을 켠 채 휴대폰(비밀)을 바꾼다 (16단계) — 지금 비밀번호 · 지금 휴대폰의 코드 · 새 otpauth 주소를 한 요청으로.
|
||||
* 2단계가 꺼지는 틈이 없다. 엔진은 새 비밀의 코드를 보지 않는다 — 새 휴대폰의 코드 확인은 부르는 쪽(TwoFactorService)이 먼저 한다
|
||||
*/
|
||||
replaceTwoFactor(
|
||||
user: string,
|
||||
currentPassword: string,
|
||||
otpCode: string,
|
||||
otpauthUrl: string,
|
||||
): Promise<TwoFactorReplaceResult>;
|
||||
|
||||
/** 앱 비밀번호 목록 (차례는 부르는 쪽이 정한다) */
|
||||
listAppPasswords(user: string): Promise<AppPasswordRecord[]>;
|
||||
|
||||
/**
|
||||
* 앱 비밀번호를 만든다 — 권한은 메일 프로그램만(로그인 · IMAP · 보내기), 만료 · 허용 IP 없음.
|
||||
* 엔진 상한을 넘으면 AUTH_009. 결과를 모르게 끝나면 MAIL_001 (값을 받지 못했으니 다시 만든다 — 목록에 남은 것은 직원이 지운다)
|
||||
*/
|
||||
createAppPassword(user: string, name: string): Promise<CreatedAppPassword>;
|
||||
|
||||
/** 앱 비밀번호 하나를 지운다 — 이미 없으면 그대로 끝난다 */
|
||||
deleteAppPassword(user: string, id: string): Promise<void>;
|
||||
|
||||
/** 그 직원의 앱 비밀번호를 모두 지운다 → 지운 수 (비밀번호를 바꿨을 때 — 계약 5절 "비밀번호 바꾸기") */
|
||||
deleteAllAppPasswords(user: string): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { buildAccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
const read =
|
||||
(values: Record<string, string>) =>
|
||||
(key: string): string | undefined =>
|
||||
values[key];
|
||||
|
||||
describe('buildAccountSecuritySettings (계약 5절 "환경 변수")', () => {
|
||||
it('서명 키 · 앱 네트워크 대역을 읽는다', () => {
|
||||
const settings = buildAccountSecuritySettings(
|
||||
read({
|
||||
MAIL_WEBHOOK_SECRET: 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a',
|
||||
APP_NETWORK_SUBNET: '10.20.0.0/16',
|
||||
}),
|
||||
);
|
||||
expect(settings.webhookSecret).toBe('Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a');
|
||||
expect(settings.appNetwork).toEqual({
|
||||
address: '10.20.0.0',
|
||||
prefix: 16,
|
||||
family: 4,
|
||||
});
|
||||
expect(settings.isAppNetworkAddress('10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('::ffff:10.20.3.4')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(false);
|
||||
});
|
||||
|
||||
it('비거나 형식이 틀리면 서명 키 없음(웹훅을 모두 거절) · 대역은 compose 기본값 172.16.71.0/24', () => {
|
||||
const cases: Record<string, string>[] = [
|
||||
{},
|
||||
{ MAIL_WEBHOOK_SECRET: '', APP_NETWORK_SUBNET: '' },
|
||||
{ MAIL_WEBHOOK_SECRET: 'short', APP_NETWORK_SUBNET: 'not-a-cidr' },
|
||||
];
|
||||
for (const values of cases) {
|
||||
const settings = buildAccountSecuritySettings(read(values));
|
||||
expect(settings.webhookSecret).toBeNull();
|
||||
expect(settings.isAppNetworkAddress('172.16.71.2')).toBe(true);
|
||||
expect(settings.isAppNetworkAddress('172.16.72.2')).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createCidrMatcher, parseCidr, type Cidr } from '@/shared/utils/cidr';
|
||||
import {
|
||||
DEFAULT_APP_NETWORK_SUBNET,
|
||||
MAIL_WEBHOOK_SECRET_PATTERN,
|
||||
} from './account-security.constants';
|
||||
|
||||
/**
|
||||
* 계정 보안 설정 (계약 5절 "환경 변수" · "엔진 웹훅 받기") — 루트 env 에서 compose 가 넣는다. 검사는 env.validation.ts 가 기동 때 한다.
|
||||
*/
|
||||
export const ACCOUNT_SECURITY_SETTINGS = Symbol('ACCOUNT_SECURITY_SETTINGS');
|
||||
|
||||
export interface AccountSecuritySettings {
|
||||
/**
|
||||
* 엔진 웹훅 서명 키(`MAIL_WEBHOOK_SECRET` — setup 이 엔진의 x:WebHook 에 같은 값을 넣는다). 없거나 형식이 틀리면 null —
|
||||
* 웹훅을 모두 401 로 거절한다 (운영은 env 검증이 필수로 막는다)
|
||||
*/
|
||||
readonly webhookSecret: string | null;
|
||||
/** 엔진이 붙는 앱 네트워크 대역(`APP_NETWORK_SUBNET` — 없으면 compose 기본값 172.16.71.0/24) — 웹훅의 소켓 주소가 이 안이어야 한다 */
|
||||
readonly appNetwork: Cidr;
|
||||
/** 소켓 주소가 앱 네트워크 안인가 */
|
||||
readonly isAppNetworkAddress: (address: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
/** env 값을 읽어 설정을 만든다 (순수 함수 — 읽기 함수를 주입받는다). 대역이 틀리면 기본값 (형식 오류는 env 검증이 기동 때 막는다) */
|
||||
export function buildAccountSecuritySettings(
|
||||
read: (key: string) => string | undefined,
|
||||
): AccountSecuritySettings {
|
||||
const secret = read('MAIL_WEBHOOK_SECRET')?.trim() ?? '';
|
||||
const configured = read('APP_NETWORK_SUBNET')?.trim();
|
||||
const appNetwork =
|
||||
(configured ? parseCidr(configured) : null) ?? defaultAppNetwork();
|
||||
return {
|
||||
webhookSecret: MAIL_WEBHOOK_SECRET_PATTERN.test(secret) ? secret : null,
|
||||
appNetwork,
|
||||
isAppNetworkAddress: createCidrMatcher(appNetwork),
|
||||
};
|
||||
}
|
||||
|
||||
/** compose 기본값의 대역 — 상수라 늘 읽힌다 */
|
||||
function defaultAppNetwork(): Cidr {
|
||||
const cidr = parseCidr(DEFAULT_APP_NETWORK_SUBNET);
|
||||
if (cidr === null) {
|
||||
throw new Error(
|
||||
`기본 앱 네트워크 대역(${DEFAULT_APP_NETWORK_SUBNET})을 읽지 못했습니다.`,
|
||||
);
|
||||
}
|
||||
return cidr;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 엔진의 JMAP id — 64비트 수의 32진수라 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 쓰인 곳 — imap · smtp */
|
||||
const METHOD_COLUMN_LENGTH = 8;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
|
||||
/** 앱 비밀번호가 쓰인 곳 (엔진 웹훅의 리스너) */
|
||||
export type AppPasswordUseMethod = 'imap' | 'smtp';
|
||||
|
||||
/**
|
||||
* 앱 비밀번호의 서버 기록 (계약 5절 "앱 비밀번호 — 마지막 사용") — 엔진에 마지막 사용 칸이 없어 웹훅의 auth.success(앱 비밀번호)로 만든다.
|
||||
* 엔진은 지운 번호를 다시 쓴다 — 그래서 **같은 번호 · 같은 createdAt** 일 때만 잇는다(목록이 엔진의 createdAt 과 견준다).
|
||||
* 만들 때 서버가 이 줄을 새로 둔다(웹메일 밖에서 만든 것은 목록을 볼 때). 이름은 그때의 이름이다 — 로그인 기록에 앱 비밀번호 이름을 남길 때 쓴다.
|
||||
* 값(비밀)은 두지 않는다
|
||||
*/
|
||||
@Entity({ name: 'app_password_usages' })
|
||||
export class AppPasswordUsageEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@PrimaryColumn({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
})
|
||||
appPasswordId!: string;
|
||||
|
||||
/** 그때의 이름 (40자까지 — 엔진에서 더 긴 이름이면 잘라 둔다) */
|
||||
@Column({ type: 'varchar', length: APP_PASSWORD_NAME_MAX_LENGTH })
|
||||
name!: string;
|
||||
|
||||
/** 엔진이 알려 준 만든 때 — 같은 번호를 다시 쓴 앱 비밀번호와 가른다 */
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
|
||||
lastUsedAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_method',
|
||||
type: 'varchar',
|
||||
length: METHOD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedMethod!: AppPasswordUseMethod | null;
|
||||
|
||||
@Column({
|
||||
name: 'last_used_ip',
|
||||
type: 'varchar',
|
||||
length: IP_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsedIp!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
APP_PASSWORD_ID_PATTERN,
|
||||
credentialNumberToId,
|
||||
isAppPasswordShape,
|
||||
} from './app-password';
|
||||
|
||||
describe('isAppPasswordShape — 엔진이 앱 비밀번호로 읽는 모양 (웹메일에서 거절한다)', () => {
|
||||
it('엔진이 만드는 값(app_ + 엔진 base32 36자)은 앱 비밀번호다', () => {
|
||||
expect(isAppPasswordShape('app_abcdefghijklmnopqrstuvwxyz0123791203')).toBe(
|
||||
true,
|
||||
);
|
||||
// 넷째 글자는 엔진이 건너뛴다 · 대문자 K-Z 도 엔진이 받는다(역표의 특이점)
|
||||
expect(isAppPasswordShape(`appX${'kLMNOPQRSTUVWXYZ'.repeat(2)}abcd`)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['39자', `app_${'a'.repeat(35)}`],
|
||||
['41자', `app_${'a'.repeat(37)}`],
|
||||
['app 으로 시작하지 않음', `ap__${'a'.repeat(36)}`],
|
||||
['대문자 APP', `APP_${'a'.repeat(36)}`],
|
||||
['엔진이 받지 않는 글자(4 · 5 · 6 · 8)', `app_${'4568'.repeat(9)}`],
|
||||
['엔진이 받지 않는 대문자 A-J', `app_${'ABCDEFGHIJ'.repeat(3)}abcdef`],
|
||||
['기호', `app_${'a'.repeat(35)}!`],
|
||||
['보통 비밀번호', 'correct-horse-battery-staple'],
|
||||
])('%s 은 앱 비밀번호가 아니다', (_label, value) => {
|
||||
expect(isAppPasswordShape(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('credentialNumberToId — 엔진의 자격 번호 → JMAP id (Stalwart Id::as_string)', () => {
|
||||
it.each([
|
||||
[0, 'a'],
|
||||
[1, 'b'],
|
||||
[2, 'c'],
|
||||
[25, 'z'],
|
||||
[26, '7'],
|
||||
[31, '3'],
|
||||
[32, 'ba'],
|
||||
[33, 'bb'],
|
||||
[1024, 'baa'],
|
||||
])('%d → %s', (value, id) => {
|
||||
expect(credentialNumberToId(value)).toBe(id);
|
||||
expect(APP_PASSWORD_ID_PATTERN.test(id)).toBe(true);
|
||||
});
|
||||
|
||||
it('음이 아닌 안전한 정수가 아니면 null', () => {
|
||||
for (const value of [-1, 1.5, Number.NaN, '1', null, undefined, 2 ** 60]) {
|
||||
expect(credentialNumberToId(value)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 앱 비밀번호 판정 (순수 함수) — 계약 5절 "앱 비밀번호".
|
||||
*/
|
||||
|
||||
/**
|
||||
* 엔진이 앱 비밀번호로 읽는 모양 — `app` + 아무 글자 하나 + 엔진의 base32 글자 36자 (모두 40자).
|
||||
* 엔진(Stalwart 0.16.27 — `auth/credential.rs`)은 `app` 으로 시작하고 넷째 글자를 건너뛴 뒤 사용자 정의 base32
|
||||
* (소문자 a-z · 숫자 0 1 2 3 7 9, 그리고 역표의 특이점으로 대문자 K-Z)로 22바이트(자격 번호 4 + 비밀 18)를 읽을 수 있으면 앱 비밀번호로 본다.
|
||||
* 엔진이 만드는 값은 `app_` + 36자다. 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기에서 이 모양이면 엔진에 묻지 않고 거절한다 —
|
||||
* 엔진에는 앱 비밀번호를 웹메일에서 빼는 설정이 없다. 우연히 이 모양인 진짜 비밀번호는 엔진도 앱 비밀번호로 읽어 어차피 실패한다.
|
||||
* 정규식은 길이가 고정이라 입력 길이에 비례해 끝난다
|
||||
*/
|
||||
const APP_PASSWORD_SHAPE = /^app[\s\S][a-zK-Z012379]{36}$/;
|
||||
|
||||
export function isAppPasswordShape(password: string): boolean {
|
||||
return password.length === 40 && APP_PASSWORD_SHAPE.test(password);
|
||||
}
|
||||
|
||||
/** 엔진의 JMAP id 글자 (Stalwart `utils::codec::base32_custom::BASE32_ALPHABET`) */
|
||||
const JMAP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
const JMAP_ID_RADIX = 32;
|
||||
|
||||
/**
|
||||
* 엔진의 자격 번호(웹훅 `data.id` — 앱 비밀번호로 들어온 성공 이벤트) → 앱 비밀번호의 JMAP id (`x:AppPassword` 의 id).
|
||||
* 엔진은 자격 번호를 그대로 JMAP id 로 적는다 — 위 글자로 쓴 32진수, 큰 자리부터(0 은 `a`, 1 은 `b` — Stalwart `types::id::Id::as_string`).
|
||||
* 음이 아닌 안전한 정수가 아니면 null
|
||||
*/
|
||||
export function credentialNumberToId(value: unknown): string | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
let rest = value;
|
||||
let id = '';
|
||||
do {
|
||||
id = JMAP_ID_ALPHABET[rest % JMAP_ID_RADIX] + id;
|
||||
rest = Math.floor(rest / JMAP_ID_RADIX);
|
||||
} while (rest > 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 앱 비밀번호 id 의 모양 — 엔진 JMAP id 글자만, 짧게 (경로 매개변수 검사) */
|
||||
export const APP_PASSWORD_ID_PATTERN = /^[a-z0-9]{1,13}$/;
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
AppPasswordsService,
|
||||
type AppPasswordView,
|
||||
} from './app-passwords.service';
|
||||
import {
|
||||
AppPasswordPageDto,
|
||||
CreateAppPasswordDto,
|
||||
CreatedAppPasswordDto,
|
||||
type AppPasswordDto,
|
||||
} from './dto/app-password.dto';
|
||||
|
||||
function toDto(view: AppPasswordView): AppPasswordDto {
|
||||
return {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
createdAt: view.createdAt.toISOString(),
|
||||
lastUsed: view.lastUsed
|
||||
? {
|
||||
at: view.lastUsed.at.toISOString(),
|
||||
method: view.lastUsed.method,
|
||||
ip: view.lastUsed.ip,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 앱 비밀번호 (계약 5절 "앱 비밀번호") — 메일 프로그램(IMAP 993 · SMTP 465)에 넣는 비밀번호. 판단은 AppPasswordsService 가 한다
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('app-passwords')
|
||||
export class AppPasswordsController {
|
||||
constructor(private readonly appPasswords: AppPasswordsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 목록 — 최근에 만든 것이 위 (한 쪽)',
|
||||
description:
|
||||
'마지막 사용(lastUsed)은 서버가 엔진의 로그인 이벤트(웹훅)로 만든 기록이다 — 같은 번호 · 같은 만든 때인 것만 잇는다. 아직 쓰지 않았으면 null.',
|
||||
})
|
||||
@ApiStandardResponse(AppPasswordPageDto, { description: '목록' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async list(@CurrentUser() user: AuthenticatedUser) {
|
||||
const page = await this.appPasswords.list(user.email);
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 만들기 — 값은 이 응답에만',
|
||||
description:
|
||||
'권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기) — 웹메일 로그인 · 비밀번호 바꾸기 · 2단계 끄기에는 쓰이지 않는다. 만료 · 허용 IP 는 두지 않는다. ' +
|
||||
'값은 엔진이 만들어 이 응답에만 싣는다(서버 · 로그에 남지 않는다 — Cache-Control: private, no-store). 2단계 인증을 켜지 않은 직원도 만든다.',
|
||||
})
|
||||
@ApiStandardResponse(CreatedAppPasswordDto, {
|
||||
status: 201,
|
||||
description: '만든 앱 비밀번호 — password 는 다시 볼 수 없다',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'이름이 비었거나 40자를 넘음 · 이미 있는 이름(앞뒤 공백을 지운 값이 같음) · 형식 오류',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_009',
|
||||
'엔진 상한(기본 5개)을 넘음 — 쓰지 않는 것을 지운 뒤 만든다',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 만들기가 처리 중(한 번에 하나)')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(값을 받지 못했다 — 목록에 남은 것은 지우고 다시 만든다)',
|
||||
)
|
||||
async create(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: CreateAppPasswordDto,
|
||||
): Promise<CreatedAppPasswordDto> {
|
||||
const created = await this.appPasswords.create(user.email, dto.name);
|
||||
return { ...toDto(created), password: created.password };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '앱 비밀번호 지우기 — 이미 없으면 200',
|
||||
description:
|
||||
'이 비밀번호로 새로 연결하는 메일 프로그램은 바로 막힌다. 이미 열린 IMAP 연결은 엔진이 끊지 않는다(다음 연결부터 — 알려진 한계).',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '앱 비밀번호 id', example: 'b' })
|
||||
@ApiStandardResponse(undefined, { description: '지웠다 (data: null)' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async remove(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
): Promise<null> {
|
||||
await this.appPasswords.remove(user.email, id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type {
|
||||
AccountSecurityProvider,
|
||||
AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import type { AppPasswordUsageEntity } from './app-password-usage.entity';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const VALUE = 'app_abcdefghijklmnopqrstuvwxyz0123791203';
|
||||
|
||||
async function failureOf(
|
||||
promise: Promise<unknown>,
|
||||
): Promise<BusinessException | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessException) return error;
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('AppPasswordsService (앱 비밀번호 — 계약 5절)', () => {
|
||||
let records: AppPasswordRecord[];
|
||||
let usageRows: AppPasswordUsageEntity[];
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
| 'listAppPasswords'
|
||||
| 'createAppPassword'
|
||||
| 'deleteAppPassword'
|
||||
| 'deleteAllAppPasswords'
|
||||
>
|
||||
>;
|
||||
let usages: {
|
||||
findBy: jest.Mock;
|
||||
findOneBy: jest.Mock;
|
||||
upsert: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let service: AppPasswordsService;
|
||||
let logs: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
records = [];
|
||||
usageRows = [];
|
||||
provider = {
|
||||
listAppPasswords: jest.fn<Promise<AppPasswordRecord[]>, [string]>(() =>
|
||||
Promise.resolve([...records]),
|
||||
),
|
||||
createAppPassword: jest.fn((_user: string, name: string) => {
|
||||
const created = {
|
||||
id: 'c',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:20:00.000Z',
|
||||
password: VALUE,
|
||||
};
|
||||
records.push({ id: 'c', name, createdAt: created.createdAt });
|
||||
return Promise.resolve(created);
|
||||
}),
|
||||
deleteAppPassword: jest.fn<Promise<void>, [string, string]>(() =>
|
||||
Promise.resolve(),
|
||||
),
|
||||
deleteAllAppPasswords: jest.fn<Promise<number>, [string]>(() =>
|
||||
Promise.resolve(records.length),
|
||||
),
|
||||
};
|
||||
usages = {
|
||||
findBy: jest.fn(() => Promise.resolve([...usageRows])),
|
||||
findOneBy: jest.fn(({ appPasswordId }: { appPasswordId: string }) =>
|
||||
Promise.resolve(
|
||||
usageRows.find((row) => row.appPasswordId === appPasswordId) ?? null,
|
||||
),
|
||||
),
|
||||
upsert: jest.fn(
|
||||
(rows: AppPasswordUsageEntity | AppPasswordUsageEntity[]) => {
|
||||
for (const row of Array.isArray(rows) ? rows : [rows]) {
|
||||
usageRows = usageRows.filter(
|
||||
(existing) => existing.appPasswordId !== row.appPasswordId,
|
||||
);
|
||||
usageRows.push({ ...row });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
createQueryBuilder: jest.fn(() => {
|
||||
const builder = {
|
||||
update: () => builder,
|
||||
set: () => builder,
|
||||
where: () => builder,
|
||||
execute: () => Promise.resolve({ affected: 1 }),
|
||||
};
|
||||
return builder;
|
||||
}),
|
||||
};
|
||||
service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
logs = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
logs.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('만들기 — 앞뒤 공백을 지운 이름으로 만들고 값은 응답에만 준다, 기록을 새로 둔다 (로그에는 가린 주소 + id 만)', async () => {
|
||||
const created = await service.create(ARA, ' 회사 PC 아웃룩 ');
|
||||
|
||||
expect(provider.createAppPassword).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'회사 PC 아웃룩',
|
||||
);
|
||||
expect(created).toEqual({
|
||||
id: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsed: null,
|
||||
password: VALUE,
|
||||
});
|
||||
expect(usageRows).toEqual([
|
||||
expect.objectContaining({
|
||||
appPasswordId: 'c',
|
||||
name: '회사 PC 아웃룩',
|
||||
createdAt: new Date('2026-10-31T00:20:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
]);
|
||||
const everything = logs.join('\n');
|
||||
expect(everything).toContain('a***@arasystem.test, id=c');
|
||||
expect(everything).not.toContain(VALUE);
|
||||
expect(everything).not.toContain('회사 PC 아웃룩');
|
||||
});
|
||||
|
||||
it('같은 이름(앞뒤 공백을 지운 값)이 있으면 400 VAL_001 — 엔진에 만들기를 보내지 않는다', async () => {
|
||||
records = [
|
||||
{
|
||||
id: 'b',
|
||||
name: '회사 PC 아웃룩 ',
|
||||
createdAt: '2026-10-30T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
expect((await failureOf(service.create(ARA, '회사 PC 아웃룩')))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(provider.createAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('엔진 상한의 거절(AUTH_009)은 그대로 던진다', async () => {
|
||||
provider.createAppPassword.mockRejectedValue(
|
||||
new BusinessException('AUTH_009'),
|
||||
);
|
||||
expect((await failureOf(service.create(ARA, '다섯째')))?.code).toBe(
|
||||
'AUTH_009',
|
||||
);
|
||||
});
|
||||
|
||||
it('목록 — 최근에 만든 것이 위, 같은 번호 · 같은 만든 때의 기록만 마지막 사용으로 잇는다', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: '집 PC', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: '휴대폰', createdAt: '2026-10-31T00:00:00.000Z' },
|
||||
];
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
lastUsedMethod: 'imap',
|
||||
lastUsedIp: '211.186.95.252',
|
||||
},
|
||||
// 엔진이 번호 c 를 다시 썼다 — 옛 기록(만든 때가 다르다)은 잇지 않는다
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'c',
|
||||
name: '지운 옛것',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-10-02T00:00:00.000Z'),
|
||||
lastUsedMethod: 'smtp',
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
|
||||
const page = await service.list(ARA);
|
||||
|
||||
expect(page.items.map(({ id }) => id)).toEqual(['c', 'b']);
|
||||
expect(page.items[0].lastUsed).toBeNull();
|
||||
expect(page.items[1].lastUsed).toEqual({
|
||||
at: new Date('2026-10-31T00:41:00.000Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
});
|
||||
expect(page.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
// 다른 앱 비밀번호의 기록은 새로 둔다(만든 때 · 이름 · 마지막 사용 없음)
|
||||
expect(usageRows.find((row) => row.appPasswordId === 'c')).toMatchObject({
|
||||
name: '휴대폰',
|
||||
createdAt: new Date('2026-10-31T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('지우기 — 모양이 아닌 id 는 엔진에 묻지 않고 그대로 끝난다', async () => {
|
||||
await service.remove(ARA, 'b');
|
||||
expect(provider.deleteAppPassword).toHaveBeenCalledWith(ARA, 'b');
|
||||
|
||||
provider.deleteAppPassword.mockClear();
|
||||
await service.remove(ARA, '../etc/passwd');
|
||||
await service.remove(ARA, 'B');
|
||||
expect(provider.deleteAppPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('비밀번호를 바꾼 뒤 모두 지운다 — 실패해도 던지지 않고 error 로그(가린 주소)', async () => {
|
||||
records = [
|
||||
{ id: 'b', name: 'x', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
{ id: 'c', name: 'y', createdAt: '2026-10-30T00:00:00.000Z' },
|
||||
];
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(2);
|
||||
expect(usages.delete).toHaveBeenCalledWith({ userEmail: ARA });
|
||||
|
||||
provider.deleteAllAppPasswords.mockRejectedValue(
|
||||
new BusinessException('MAIL_001'),
|
||||
);
|
||||
await expect(service.removeAllAfterPasswordChange(ARA)).resolves.toBe(0);
|
||||
expect(logs.join('\n')).toMatch(
|
||||
/\[SYS_001\].*앱 비밀번호를 지우지 못했습니다.*a\*\*\*@arasystem\.test/,
|
||||
);
|
||||
});
|
||||
|
||||
it('웹훅의 사용 — 기록이 있으면 마지막 사용을 고치고 그때의 이름을 돌려준다, 없으면 null', async () => {
|
||||
usageRows = [
|
||||
{
|
||||
userEmail: ARA,
|
||||
appPasswordId: 'b',
|
||||
name: '집 PC',
|
||||
createdAt: new Date('2026-10-30T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
];
|
||||
const use = { at: new Date(), method: 'imap' as const, ip: '1.2.3.4' };
|
||||
await expect(service.recordUse(ARA, 'b', use)).resolves.toBe('집 PC');
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
await expect(service.recordUse(ARA, 'z', use)).resolves.toBeNull();
|
||||
expect(usages.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppPasswordsService.create — 같은 직원의 만들기는 한 번에 하나 (같은 이름 검사가 동시 요청에서 겹치지 않게)', () => {
|
||||
it('앞 만들기가 끝나기 전에 온 만들기는 429 REQ_001 — 끝나면 다시 된다, 다른 직원은 따로', async () => {
|
||||
let finish!: () => void;
|
||||
const provider = {
|
||||
listAppPasswords: jest.fn(() => Promise.resolve([])),
|
||||
createAppPassword: jest.fn(
|
||||
(_user: string, name: string) =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
id: 'b',
|
||||
name,
|
||||
createdAt: '2026-10-31T00:00:00.000Z',
|
||||
password: `app_${'a'.repeat(36)}`,
|
||||
});
|
||||
}),
|
||||
),
|
||||
};
|
||||
const usages = { upsert: jest.fn(() => Promise.resolve()) };
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const service = new AppPasswordsService(
|
||||
provider as unknown as AccountSecurityProvider,
|
||||
usages as unknown as Repository<AppPasswordUsageEntity>,
|
||||
);
|
||||
|
||||
const first = service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await service.create('ara.kim@arasystem.test', '회사 PC');
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('REQ_001');
|
||||
expect(provider.createAppPassword).toHaveBeenCalledTimes(1);
|
||||
|
||||
finish();
|
||||
await expect(first).resolves.toMatchObject({ id: 'b' });
|
||||
|
||||
provider.listAppPasswords.mockResolvedValueOnce([]);
|
||||
const again = service.create('ara.kim@arasystem.test', '휴대폰');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finish();
|
||||
await expect(again).resolves.toMatchObject({ name: '휴대폰' });
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Not, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
import {
|
||||
ACCOUNT_SECURITY_PROVIDER,
|
||||
type AccountSecurityProvider,
|
||||
type AppPasswordRecord,
|
||||
} from './account-security.provider';
|
||||
import { APP_PASSWORD_ID_PATTERN } from './app-password';
|
||||
import {
|
||||
AppPasswordUsageEntity,
|
||||
type AppPasswordUseMethod,
|
||||
} from './app-password-usage.entity';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버 기록 */
|
||||
export interface AppPasswordLastUse {
|
||||
readonly at: Date;
|
||||
readonly method: AppPasswordUseMethod;
|
||||
readonly ip: string | null;
|
||||
}
|
||||
|
||||
/** 목록의 앱 비밀번호 하나 */
|
||||
export interface AppPasswordView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly createdAt: Date;
|
||||
readonly lastUsed: AppPasswordLastUse | null;
|
||||
}
|
||||
|
||||
/** 방금 만든 앱 비밀번호 — 값은 이 응답에만 */
|
||||
export interface CreatedAppPasswordView extends AppPasswordView {
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 화면에 둘 이름 — 앞뒤 공백을 지운 값 (견줄 때도 이것으로) */
|
||||
function displayName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 비밀번호 (계약 5절 "앱 비밀번호") — 엔진의 x:AppPassword (대리 접근).
|
||||
*
|
||||
* - 값(password)은 엔진이 만들어 만들 때 한 번만 준다 — 서버는 응답에 실어 넘기기만 한다(로그 · DB · 캐시 없음)
|
||||
* - 권한은 메일 프로그램만(로그인 · IMAP · 메일 보내기 — 어댑터가 늘 좁힌다). 만료 · 허용 IP 는 두지 않는다
|
||||
* - 같은 이름(앞뒤 공백을 지운 값이 같음)은 400 VAL_001, 엔진 상한(기본 5개)을 넘으면 409 AUTH_009.
|
||||
* 같은 직원의 만들기는 한 번에 하나다 — 처리 중이면 429 REQ_001 (동시 요청이 같은 이름 검사를 함께 지나가지 않게 — 2단계 잠금과 같은 결)
|
||||
* - 목록은 최근에 만든 것이 위다. 마지막 사용은 서버 기록(AppPasswordUsageEntity)에서 — 같은 번호 · 같은 createdAt 일 때만 잇는다
|
||||
* - 웹메일 비밀번호를 바꾸면 모두 지운다(removeAllAfterPasswordChange — 실패해도 던지지 않고 error 로그)
|
||||
* 이름(사용자 글) · 값은 로그에 남기지 않는다 — 만들기 · 지우기는 info 에 가린 주소 + id
|
||||
*
|
||||
* ⚠️ 만들기 잠금은 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppPasswordsService {
|
||||
private readonly logger = new Logger(AppPasswordsService.name);
|
||||
/** 만들기가 진행 중인 직원 주소 */
|
||||
private readonly creating = new Set<string>();
|
||||
|
||||
constructor(
|
||||
@Inject(ACCOUNT_SECURITY_PROVIDER)
|
||||
private readonly provider: AccountSecurityProvider,
|
||||
@InjectRepository(AppPasswordUsageEntity)
|
||||
private readonly usages: Repository<AppPasswordUsageEntity>,
|
||||
) {}
|
||||
|
||||
/** GET /api/app-passwords — 한 쪽 (엔진 상한이 작다), total 포함 */
|
||||
async list(user: string): Promise<PaginatedResult<AppPasswordView>> {
|
||||
const address = normalizeEmail(user);
|
||||
const records = await this.provider.listAppPasswords(address);
|
||||
const items = await this.withUsage(address, records);
|
||||
return toPaginatedResult(items, {
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords — 같은 직원의 만들기가 처리 중이면 REQ_001 (확인과 차지 사이에 await 가 없다) */
|
||||
async create(user: string, name: string): Promise<CreatedAppPasswordView> {
|
||||
const address = normalizeEmail(user);
|
||||
if (this.creating.has(address)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'app-password-create-in-progress',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.creating.add(address);
|
||||
try {
|
||||
return await this.createOne(address, displayName(name));
|
||||
} finally {
|
||||
this.creating.delete(address);
|
||||
}
|
||||
}
|
||||
|
||||
private async createOne(
|
||||
address: string,
|
||||
trimmed: string,
|
||||
): Promise<CreatedAppPasswordView> {
|
||||
const existing = await this.provider.listAppPasswords(address);
|
||||
if (existing.some((record) => displayName(record.name) === trimmed)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: {
|
||||
reason: 'app-password-name-taken',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const created = await this.provider.createAppPassword(address, trimmed);
|
||||
const createdAt = new Date(created.createdAt);
|
||||
try {
|
||||
// 만들 때 기록을 새로 둔다 — 엔진이 지운 번호를 다시 썼으면 옛 기록(그때의 마지막 사용)을 덮는다
|
||||
await this.usages.upsert(
|
||||
{
|
||||
userEmail: address,
|
||||
appPasswordId: created.id,
|
||||
name: trimmed.slice(0, APP_PASSWORD_NAME_MAX_LENGTH),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
},
|
||||
['userEmail', 'appPasswordId'],
|
||||
);
|
||||
} catch (error) {
|
||||
// 값은 이미 만들어졌다 — 실패로 답하면 직원이 값을 받지 못한다. 기록은 목록을 볼 때 다시 둔다
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 두지 못했습니다(앱 비밀번호는 만들었다): user=${maskEmail(address)}, id=${created.id}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 만들었습니다: ${maskEmail(address)}, id=${created.id}`,
|
||||
);
|
||||
return {
|
||||
id: created.id,
|
||||
name: trimmed,
|
||||
createdAt,
|
||||
lastUsed: null,
|
||||
password: created.password,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/app-passwords/{id} — 이미 없으면 그대로 끝난다.
|
||||
* 모양이 아닌 id 는 있을 수 없는 앱 비밀번호다 — 엔진에 묻지 않고 "이미 없음"과 같게 끝낸다
|
||||
*/
|
||||
async remove(user: string, id: string): Promise<void> {
|
||||
if (!APP_PASSWORD_ID_PATTERN.test(id)) return;
|
||||
const address = normalizeEmail(user);
|
||||
await this.provider.deleteAppPassword(address, id);
|
||||
await this.usages.delete({ userEmail: address, appPasswordId: id });
|
||||
this.logger.log(
|
||||
`앱 비밀번호를 지웠습니다: ${maskEmail(address)}, id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹메일 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 앱 비밀번호를 모두 지운다 (계약 5절 "비밀번호 바꾸기") → 지운 수.
|
||||
* 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있다. 실패하면 error 로그(남은 앱 비밀번호가 있다 — 운영자가 지울 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
async removeAllAfterPasswordChange(user: string): Promise<number> {
|
||||
const address = normalizeEmail(user);
|
||||
try {
|
||||
const removed = await this.provider.deleteAllAppPasswords(address);
|
||||
await this.usages.delete({ userEmail: address });
|
||||
return removed;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 비밀번호 바꾸기 뒤 앱 비밀번호를 지우지 못했습니다 — 남은 앱 비밀번호는 메일 프로그램에서 계속 쓰입니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 앱 비밀번호 로그인 — 그 번호의 기록에 마지막 사용을 남긴다(지금 남은 것보다 늦을 때만 — 늦게 온 묶음이 되돌리지 않게) →
|
||||
* 그 기록의 이름(로그인 기록에 남긴다). 기록이 없으면(웹메일 밖에서 만들고 목록도 보지 않음) null
|
||||
*/
|
||||
async recordUse(
|
||||
user: string,
|
||||
id: string,
|
||||
use: AppPasswordLastUse,
|
||||
): Promise<string | null> {
|
||||
const address = normalizeEmail(user);
|
||||
const row = await this.usages.findOneBy({
|
||||
userEmail: address,
|
||||
appPasswordId: id,
|
||||
});
|
||||
if (!row) return null;
|
||||
await this.usages
|
||||
.createQueryBuilder()
|
||||
.update(AppPasswordUsageEntity)
|
||||
.set({
|
||||
lastUsedAt: use.at,
|
||||
lastUsedMethod: use.method,
|
||||
lastUsedIp: use.ip,
|
||||
})
|
||||
.where(
|
||||
'user_email = :address AND app_password_id = :id AND (last_used_at IS NULL OR last_used_at < :at)',
|
||||
{ address, id, at: use.at },
|
||||
)
|
||||
.execute();
|
||||
return row.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 목록 + 서버 기록 → 최근에 만든 것이 위. 같은 번호 · 같은 createdAt 인 기록만 잇고,
|
||||
* 기록이 없거나 다른 앱 비밀번호의 것이면(엔진이 번호를 다시 씀) 새로 둔다. 엔진에 없는 번호의 기록은 지운다
|
||||
*/
|
||||
private async withUsage(
|
||||
address: string,
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<AppPasswordView[]> {
|
||||
const rows = await this.usages.findBy({ userEmail: address });
|
||||
const byId = new Map(rows.map((row) => [row.appPasswordId, row]));
|
||||
const fresh: AppPasswordUsageEntity[] = [];
|
||||
|
||||
const items = records.map((record): AppPasswordView => {
|
||||
const createdAt = new Date(record.createdAt);
|
||||
const row = byId.get(record.id);
|
||||
const same =
|
||||
row !== undefined && row.createdAt.getTime() === createdAt.getTime();
|
||||
if (!same) {
|
||||
fresh.push(
|
||||
Object.assign(new AppPasswordUsageEntity(), {
|
||||
userEmail: address,
|
||||
appPasswordId: record.id,
|
||||
name: displayName(record.name).slice(
|
||||
0,
|
||||
APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
),
|
||||
createdAt,
|
||||
lastUsedAt: null,
|
||||
lastUsedMethod: null,
|
||||
lastUsedIp: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
name: displayName(record.name),
|
||||
createdAt,
|
||||
lastUsed:
|
||||
same && row.lastUsedAt && row.lastUsedMethod
|
||||
? {
|
||||
at: row.lastUsedAt,
|
||||
method: row.lastUsedMethod,
|
||||
ip: row.lastUsedIp,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
await this.saveUsageRows(address, fresh, records);
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime() ||
|
||||
b.id.localeCompare(a.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록을 본 김에 기록을 엔진에 맞춘다 — 실패해도 목록은 그대로 준다(마지막 사용이 빠질 뿐) */
|
||||
private async saveUsageRows(
|
||||
address: string,
|
||||
fresh: readonly AppPasswordUsageEntity[],
|
||||
records: readonly AppPasswordRecord[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (fresh.length > 0) {
|
||||
await this.usages.upsert([...fresh], ['userEmail', 'appPasswordId']);
|
||||
}
|
||||
const ids = records.map((record) => record.id);
|
||||
await this.usages.delete(
|
||||
ids.length > 0
|
||||
? { userEmail: address, appPasswordId: Not(In(ids)) }
|
||||
: { userEmail: address },
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[SYS_001]${traceTag()} 앱 비밀번호 기록을 엔진 목록에 맞추지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
import {
|
||||
MaxCodeUnits,
|
||||
MinCodeUnits,
|
||||
} from '@/common/decorators/code-units.decorator';
|
||||
import { TrimString } from '@/common/decorators/trim-string.decorator';
|
||||
import { PageInfoDto } from '@/common/dto/pagination.dto';
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from '@/modules/account-security/account-security.constants';
|
||||
|
||||
/** 앱 비밀번호의 마지막 사용 — 서버가 엔진 웹훅으로 만든 기록 */
|
||||
export class AppPasswordLastUseDto {
|
||||
@ApiProperty({
|
||||
description: '마지막으로 쓴 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
at!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '쓴 곳 — imap(메일 받기) · smtp(메일 보내기)',
|
||||
enum: ['imap', 'smtp'],
|
||||
example: 'imap',
|
||||
})
|
||||
method!: 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({
|
||||
description: '메일 프로그램의 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
}
|
||||
|
||||
export class AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 id — 지우기에 쓴다 (엔진이 지운 번호를 다시 쓴다)',
|
||||
example: 'b',
|
||||
})
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: '이름', example: '회사 PC 아웃룩' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '만든 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:20:00.000Z',
|
||||
})
|
||||
createdAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '마지막 사용 — 아직 쓰지 않았으면 null',
|
||||
type: AppPasswordLastUseDto,
|
||||
nullable: true,
|
||||
})
|
||||
lastUsed!: AppPasswordLastUseDto | null;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords → 201 — 값은 이 응답에만 */
|
||||
export class CreatedAppPasswordDto extends AppPasswordDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 비밀번호 값 — 엔진이 만든 40자(`app_` + 36자). 이 응답에만 있고 다시 볼 수 없다(서버는 두지 않는다)',
|
||||
example: 'app_abcdefghijklmnopqrstuvwxyz0123791203',
|
||||
})
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class AppPasswordPageDto {
|
||||
@ApiProperty({
|
||||
type: [AppPasswordDto],
|
||||
description: '최근에 만든 것이 위 — 한 쪽에 모두 (엔진 상한이 작다)',
|
||||
})
|
||||
items!: AppPasswordDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'hasNext false · nextCursor null · total 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
|
||||
/** POST /api/app-passwords */
|
||||
export class CreateAppPasswordDto {
|
||||
@ApiProperty({
|
||||
description: `이름 — 1~${APP_PASSWORD_NAME_MAX_LENGTH}자(앞뒤 공백을 지운 값, JS 문자열 길이). 이미 있는 이름이면 400 VAL_001`,
|
||||
minLength: 1,
|
||||
maxLength: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
@applyDecorators(
|
||||
TrimString(),
|
||||
IsString({ message: '이름을 넣어 주세요.' }),
|
||||
MinCodeUnits(1, { message: '이름을 넣어 주세요.' }),
|
||||
MaxCodeUnits(APP_PASSWORD_NAME_MAX_LENGTH, {
|
||||
message: `이름은 ${APP_PASSWORD_NAME_MAX_LENGTH}자 이하로 넣어 주세요.`,
|
||||
}),
|
||||
)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, type TransformFnParams } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
import {
|
||||
CursorPaginationQueryDto,
|
||||
PageInfoDto,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
|
||||
/** 쿼리의 'true' · 'false' → 불리언 (그 밖의 값은 그대로 두어 IsBoolean 이 VAL_001 로 막는다) */
|
||||
function toBooleanQuery({ value }: TransformFnParams): unknown {
|
||||
const raw: unknown = value;
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** GET /api/login-events 의 쿼리 — 커서 · 개수(기본 20 · 최대 100) · 성공만/실패만 */
|
||||
export class LoginEventQueryDto extends CursorPaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'false 면 실패만, true 면 성공만 — 없으면 전체',
|
||||
enum: ['true', 'false'],
|
||||
example: 'false',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toBooleanQuery)
|
||||
@IsBoolean({ message: 'isSuccess 는 true 또는 false 여야 합니다.' })
|
||||
isSuccess?: boolean;
|
||||
}
|
||||
|
||||
/** 브라우저 · 운영체제의 짧은 이름 (shared/utils/user-agent.ts) — 모르면 null */
|
||||
export class DeviceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet · ARA SYSTEM 앱(모바일 앱 — 계약 1절 "앱 로그인") — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Chrome',
|
||||
})
|
||||
browser!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Windows · macOS · iOS · Android · Linux · ChromeOS — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Windows',
|
||||
})
|
||||
os!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventAppPasswordDto {
|
||||
@ApiProperty({ description: '앱 비밀번호 id', example: 'b' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '그때의 이름 (지운 뒤에도 남는다) — 서버 기록이 없으면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '회사 PC 아웃룩',
|
||||
})
|
||||
name!: string | null;
|
||||
}
|
||||
|
||||
export class LoginEventDto {
|
||||
@ApiProperty({ description: '기록 id', example: '1042' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '일어난 때 (ISO 8601 UTC)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:41:00.000Z',
|
||||
})
|
||||
occurredAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'webmail · app(모바일 앱 — 계약 1절 "앱 로그인") · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
|
||||
enum: ['webmail', 'app', 'imap', 'smtp'],
|
||||
example: 'webmail',
|
||||
})
|
||||
method!: 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({ description: '성공했는가', example: true })
|
||||
isSuccess!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 · 앱 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
|
||||
example: true,
|
||||
})
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'실패 까닭 — wrongPassword(비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함) · wrongCode(코드 틀림 · 다시 쓴 코드) · ' +
|
||||
'appPasswordRequired(2단계를 켠 계정에 메일 프로그램이 비밀번호로). 성공이면 null',
|
||||
type: String,
|
||||
enum: ['wrongPassword', 'wrongCode', 'appPasswordRequired'],
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
failureReason!: 'wrongPassword' | 'wrongCode' | 'appPasswordRequired' | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '클라이언트 IP — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '211.186.95.252',
|
||||
})
|
||||
ip!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 · 앱의 브라우저(앱은 ARA SYSTEM 앱) · 운영체제 — 메일 프로그램이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
device!: DeviceDto | null;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'메일 프로그램이 앱 비밀번호로 들어왔으면 그 앱 비밀번호 — 아니면 null',
|
||||
type: LoginEventAppPasswordDto,
|
||||
nullable: true,
|
||||
})
|
||||
appPassword!: LoginEventAppPasswordDto | null;
|
||||
}
|
||||
|
||||
export class LoginEventPageDto {
|
||||
@ApiProperty({ type: [LoginEventDto], description: '최근 차례' })
|
||||
items!: LoginEventDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: PageInfoDto,
|
||||
description: 'total — 최근 90일 · 거른 개수',
|
||||
})
|
||||
pageInfo!: PageInfoDto;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
CurrentPasswordField,
|
||||
OtpCodeField,
|
||||
} from '@/modules/auth/dto/security-fields';
|
||||
import { MAX_PASSWORD_LENGTH } from '@/modules/auth/auth.constants';
|
||||
|
||||
/** GET /api/two-factor — 켜기 · 끄기의 응답도 같다 (계약 5절 "2단계 인증") */
|
||||
export class TwoFactorStatusDto {
|
||||
@ApiProperty({
|
||||
description: '엔진에서 2단계 인증이 켜져 있는가 — 엔진이 기준이다',
|
||||
example: true,
|
||||
})
|
||||
isEnabled!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일에서 켠 때 (ISO 8601 UTC) — 꺼져 있거나 엔진 관리 화면에서 켰으면 null',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
example: '2026-10-31T00:12:00.000Z',
|
||||
})
|
||||
enabledAt!: string | null;
|
||||
|
||||
@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) */
|
||||
export class TwoFactorEnrollmentDto {
|
||||
@ApiProperty({
|
||||
description: '등록 id — 켜기(activation)에 그대로 보낸다',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'비밀 — 160비트를 base32(RFC 4648, 채움 없음)로 적은 32자. QR 을 찍을 수 없을 때 직원이 직접 넣는다. 이 응답에만 있다(서버 · 로그에 남지 않는다)',
|
||||
example: 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP',
|
||||
})
|
||||
secret!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 넣을 otpauth 주소 — 화면이 이 주소로 QR 을 그린다. 발급자 「ARA SYSTEM」 · SHA1 · 6자리 · 30초',
|
||||
example:
|
||||
'otpauth://totp/ARA%20SYSTEM:ara.kim%40arasystem.it.kr?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30',
|
||||
})
|
||||
otpauthUrl!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '이때까지 켠다 (등록을 시작하고 10분) — 지나면 409 AUTH_008',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-10-31T00:22:00.000Z',
|
||||
})
|
||||
expiresAt!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
export class TwoFactorActivationDto {
|
||||
@ApiProperty({
|
||||
description: '등록 응답의 enrollmentId',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'인증 앱에 보이는 숫자 6자리 — 등록의 비밀로 서버가 먼저 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 — 엔진이 2단계 인증을 켤 때 요구한다 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification (16단계 — 휴대폰 바꾸기 ①) */
|
||||
export class TwoFactorVerificationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'새 휴대폰 인증 앱의 숫자 6자리 — 등록의 비밀로 서버가 확인한다(앞뒤 30초까지)',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments/{id}/verification → 200 */
|
||||
export class TwoFactorVerifiedDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'늘 true — 등록에 "확인됨"을 달았다(10분 안에 바꾸기를 보낸다)',
|
||||
example: true,
|
||||
})
|
||||
verified!: true;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/replacements (16단계 — 휴대폰 바꾸기 ②) */
|
||||
export class TwoFactorReplacementDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'확인한 등록의 enrollmentId (POST /api/two-factor/enrollments → …/verification)',
|
||||
example: '1f0c2a5e-3c1d-4b8e-9f57-6e2d1b0a9c44',
|
||||
})
|
||||
@IsUUID('all', { message: '2단계 인증 등록 정보가 올바르지 않습니다.' })
|
||||
enrollmentId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '654321',
|
||||
})
|
||||
@OtpCodeField()
|
||||
currentOtpCode!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/deactivation */
|
||||
export class TwoFactorDeactivationDto {
|
||||
@ApiProperty({
|
||||
description: `지금 비밀번호 (${MAX_PASSWORD_LENGTH}자 이하, 앱 비밀번호는 받지 않는다)`,
|
||||
format: 'password',
|
||||
example: 'current-password',
|
||||
})
|
||||
@CurrentPasswordField()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '지금 휴대폰 인증 앱의 숫자 6자리 — 엔진이 확인한다',
|
||||
example: '123456',
|
||||
})
|
||||
@OtpCodeField()
|
||||
otpCode!: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
describe('로그인 기록의 커서 (자리 — 일어난 때 · 차례 번호)', () => {
|
||||
const position = {
|
||||
occurredAt: new Date('2026-10-31T00:41:00.000Z'),
|
||||
id: '1042',
|
||||
};
|
||||
|
||||
it('같은 거르기로 읽으면 자리가 그대로 나온다', () => {
|
||||
const scope = loginEventScope(false);
|
||||
const cursor = encodeLoginEventCursor(position, scope);
|
||||
expect(decodeLoginEventCursor(cursor, scope)).toEqual({ position });
|
||||
});
|
||||
|
||||
it('다른 거르기(전체 · 성공만 · 실패만)의 커서는 받지 않는다', () => {
|
||||
const cursor = encodeLoginEventCursor(position, loginEventScope(undefined));
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(false))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
expect(decodeLoginEventCursor(cursor, loginEventScope(true))).toEqual({
|
||||
problem: 'other-query',
|
||||
});
|
||||
});
|
||||
|
||||
it('모양이 틀리면 까닭과 함께 거절한다', () => {
|
||||
const scope = loginEventScope(undefined);
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
expect(decodeLoginEventCursor('a'.repeat(300), scope)).toEqual({
|
||||
problem: 'too-long',
|
||||
});
|
||||
expect(decodeLoginEventCursor('@@@', scope)).toEqual({
|
||||
problem: 'not-json',
|
||||
});
|
||||
for (const bad of [
|
||||
{ v: 2, t: position.occurredAt.toISOString(), i: '1', s: 'x' },
|
||||
{ v: 1, t: 'not-a-date', i: '1', s: 'x' },
|
||||
{ v: 1, t: '2026-10-31T00:41:00Z', i: '1', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '0', s: 'x' },
|
||||
{ v: 1, t: position.occurredAt.toISOString(), i: '1; DROP', s: 'x' },
|
||||
]) {
|
||||
expect(decodeLoginEventCursor(encode(bad), scope)).toEqual({
|
||||
problem: 'malformed',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* 로그인 기록의 커서 (순수 함수) — 최근 차례(일어난 때 · 차례 번호의 내림차순)에서 마지막으로 준 기록의 자리.
|
||||
* 위치(offset)가 아니라 자리(keyset)를 적는다 — 새 기록은 맨 위에 쌓이므로 위치 커서면 다음 쪽이 밀려 겹친다.
|
||||
* 다른 거르기(isSuccess)의 커서를 섞어 쓰지 못하게 범위의 해시를 함께 넣는다 (shared/utils/offset-cursor.ts 와 같은 결).
|
||||
*/
|
||||
const CURSOR_VERSION = 1;
|
||||
const MAX_CURSOR_LENGTH = 256;
|
||||
const SCOPE_HASH_LENGTH = 16;
|
||||
/** 차례 번호 — bigint 의 10진 표기 */
|
||||
const ID_PATTERN = /^[1-9]\d{0,18}$/;
|
||||
|
||||
/** 마지막으로 준 기록의 자리 */
|
||||
export interface LoginEventPosition {
|
||||
readonly occurredAt: Date;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type LoginEventCursorReading =
|
||||
| { readonly position: LoginEventPosition }
|
||||
| { readonly problem: 'too-long' | 'not-json' | 'malformed' | 'other-query' };
|
||||
|
||||
interface EncodedCursor {
|
||||
v: number;
|
||||
t: string;
|
||||
i: string;
|
||||
s: string;
|
||||
}
|
||||
|
||||
/** 커서의 범위 — 거르기 값 (전체 · 성공만 · 실패만) */
|
||||
export function loginEventScope(isSuccess: boolean | undefined): string {
|
||||
return isSuccess === undefined ? 'all' : String(isSuccess);
|
||||
}
|
||||
|
||||
function scopeHash(scope: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`login-events:${scope}`)
|
||||
.digest('base64url')
|
||||
.slice(0, SCOPE_HASH_LENGTH);
|
||||
}
|
||||
|
||||
export function encodeLoginEventCursor(
|
||||
position: LoginEventPosition,
|
||||
scope: string,
|
||||
): string {
|
||||
const payload: EncodedCursor = {
|
||||
v: CURSOR_VERSION,
|
||||
t: position.occurredAt.toISOString(),
|
||||
i: position.id,
|
||||
s: scopeHash(scope),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
}
|
||||
|
||||
/** 커서를 읽는다 — 형식이 틀리거나 다른 범위의 커서면 그 까닭 (부르는 쪽이 VAL_001 로 바꾼다) */
|
||||
export function decodeLoginEventCursor(
|
||||
cursor: string,
|
||||
scope: string,
|
||||
): LoginEventCursorReading {
|
||||
if (cursor.length > MAX_CURSOR_LENGTH) return { problem: 'too-long' };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return { problem: 'not-json' };
|
||||
}
|
||||
const { v, t, i, s } = (parsed ?? {}) as Partial<EncodedCursor>;
|
||||
if (
|
||||
v !== CURSOR_VERSION ||
|
||||
typeof t !== 'string' ||
|
||||
typeof i !== 'string' ||
|
||||
typeof s !== 'string' ||
|
||||
!ID_PATTERN.test(i)
|
||||
) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
const occurredAt = new Date(t);
|
||||
if (Number.isNaN(occurredAt.getTime()) || occurredAt.toISOString() !== t) {
|
||||
return { problem: 'malformed' };
|
||||
}
|
||||
if (s !== scopeHash(scope)) return { problem: 'other-query' };
|
||||
return { position: { occurredAt, id: i } };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
import { APP_PASSWORD_NAME_MAX_LENGTH } from './account-security.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 방식 · 실패 까닭 같은 짧은 낱말 */
|
||||
const WORD_COLUMN_LENGTH = 32;
|
||||
/** IP 문자열 — IPv6 최대 45자에 여유 */
|
||||
const IP_COLUMN_LENGTH = 64;
|
||||
/** User-Agent 원문 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
/** 엔진의 JMAP id — 13자까지 */
|
||||
const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */
|
||||
const DEDUPE_KEY_LENGTH = 43;
|
||||
|
||||
/** 로그인 방식 — 웹메일 · 앱(모바일 — 계약 1절 "앱 로그인") · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
|
||||
export type LoginEventMethod = 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
/** 서버가 판정한 로그인의 방식 — 웹메일 · 앱 (메일 프로그램은 엔진 웹훅이 알려 준다) */
|
||||
export type SignInMethod = Extract<LoginEventMethod, 'webmail' | 'app'>;
|
||||
|
||||
/**
|
||||
* 실패 까닭 (계약 5절 "로그인 기록"):
|
||||
* - wrongPassword: 비밀번호 틀림 — 웹메일에 넣은 앱 비밀번호 포함
|
||||
* - wrongCode: 2단계 인증 코드 틀림 · 다시 쓴 코드
|
||||
* - appPasswordRequired: 2단계를 켠 계정에 메일 프로그램이 비밀번호로 들어오려 함 (엔진 auth.mfa-required)
|
||||
*/
|
||||
export type LoginFailureReason =
|
||||
| 'wrongPassword'
|
||||
| 'wrongCode'
|
||||
| 'appPasswordRequired';
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts).
|
||||
* - 웹메일 · 앱: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
* - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다)
|
||||
*/
|
||||
@Entity({ name: 'login_events' })
|
||||
@Index('IDX_login_events_user_occurred', ['userEmail', 'occurredAt', 'id'])
|
||||
export class LoginEventEntity {
|
||||
/** 차례 번호 — 응답에서는 문자열 (pg 는 bigint 를 문자열로 준다) */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 일어난 때 — 웹메일은 서버가 판정한 때, 메일 프로그램은 엔진 이벤트의 createdAt(초 단위) */
|
||||
@Index('IDX_login_events_occurred_at')
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: WORD_COLUMN_LENGTH })
|
||||
method!: LoginEventMethod;
|
||||
|
||||
@Column({ name: 'is_success', type: 'boolean' })
|
||||
isSuccess!: boolean;
|
||||
|
||||
/** 웹메일 · 앱 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
|
||||
@Column({ name: 'is_two_factor', type: 'boolean' })
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@Column({
|
||||
name: 'failure_reason',
|
||||
type: 'varchar',
|
||||
length: WORD_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
failureReason!: LoginFailureReason | null;
|
||||
|
||||
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
|
||||
ip!: string | null;
|
||||
|
||||
/** 웹메일 · 앱의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
length: USER_AGENT_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
userAgent!: string | null;
|
||||
|
||||
/** 메일 프로그램이 앱 비밀번호로 들어왔으면 그 id */
|
||||
@Column({
|
||||
name: 'app_password_id',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_ID_COLUMN_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordId!: string | null;
|
||||
|
||||
/** 그때의 앱 비밀번호 이름 — 서버 기록이 없으면 null */
|
||||
@Column({
|
||||
name: 'app_password_name',
|
||||
type: 'varchar',
|
||||
length: APP_PASSWORD_NAME_MAX_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
appPasswordName!: string | null;
|
||||
|
||||
/** 엔진 이벤트의 겹침 거르기 키 — 이벤트 내용의 지문 (웹메일 기록은 null). 엔진이 다시 보내면 이 키가 같다 */
|
||||
@Index('UQ_login_events_dedupe_key', { unique: true })
|
||||
@Column({
|
||||
name: 'dedupe_key',
|
||||
type: 'char',
|
||||
length: DEDUPE_KEY_LENGTH,
|
||||
nullable: true,
|
||||
})
|
||||
dedupeKey!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
/**
|
||||
* 로그인 기록 정리 — 90일이 지난 기록을 날마다 지운다 (계약 5절 "로그인 기록").
|
||||
*
|
||||
* ⚠️ 여러 인스턴스로 늘리면 모든 인스턴스가 같은 시각에 같은 DELETE 를 실행한다 (결과는 같지만 낭비).
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsCleanup {
|
||||
private readonly logger = new Logger(LoginEventsCleanup.name);
|
||||
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM, { name: 'login-events-cleanup' })
|
||||
async purgeOldEvents(now: Date = new Date()): Promise<number> {
|
||||
try {
|
||||
const removed = await this.loginEvents.purge(now);
|
||||
// 반복되는 정상 작업이라 info 로 남기지 않는다 (.claude/rules/logging.md)
|
||||
if (removed > 0) {
|
||||
this.logger.debug(`90일이 지난 로그인 기록 ${removed}건을 지웠습니다.`);
|
||||
}
|
||||
return removed;
|
||||
} catch (error) {
|
||||
// 정리 작업이 실패해도 앱은 계속 돈다 — 다음 날 다시 시도한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 로그인 기록 정리에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
describeAppUserAgent,
|
||||
describeUserAgent,
|
||||
} from '@/shared/utils/user-agent';
|
||||
import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants';
|
||||
import {
|
||||
LoginEventPageDto,
|
||||
LoginEventQueryDto,
|
||||
type LoginEventDto,
|
||||
} from './dto/login-event.dto';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
|
||||
function toDto(event: LoginEventEntity): LoginEventDto {
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
method: event.method,
|
||||
isSuccess: event.isSuccess,
|
||||
isTwoFactor: event.isTwoFactor,
|
||||
failureReason: event.failureReason,
|
||||
ip: event.ip,
|
||||
// 웹메일 · 앱만 기기를 안다(User-Agent) — 메일 프로그램은 엔진이 알려 주지 않는다.
|
||||
// 앱 이름(ARA SYSTEM 앱)은 서버가 앱 경로로 판정한 기록에만 — 웹메일 기록의 User-Agent 가 앱 꼴이어도 붙이지 않는다
|
||||
device:
|
||||
event.method === 'app'
|
||||
? describeAppUserAgent(event.userAgent)
|
||||
: event.method === 'webmail'
|
||||
? describeUserAgent(event.userAgent)
|
||||
: null,
|
||||
appPassword: event.appPasswordId
|
||||
? { id: event.appPasswordId, name: event.appPasswordName }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱과 메일 프로그램(IMAP · SMTP)의 로그인
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)')
|
||||
@Controller('login-events')
|
||||
export class LoginEventsController {
|
||||
constructor(private readonly loginEvents: LoginEventsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`,
|
||||
description:
|
||||
'웹메일 · 앱: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
|
||||
'메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' +
|
||||
`기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`,
|
||||
})
|
||||
@ApiStandardResponse(LoginEventPageDto, { description: '한 쪽' })
|
||||
@ApiErrorResponse('VAL_001', '쿼리 형식 오류 · 다른 거르기의 커서')
|
||||
async list(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Query() query: LoginEventQueryDto,
|
||||
): Promise<LoginEventPageDto> {
|
||||
const page = await this.loginEvents.list(user.email, {
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
return { items: page.items.map(toDto), pageInfo: page.pageInfo };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { LOGIN_EVENT_INSERT_CHUNK } from './account-security.constants';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService, retentionStart } from './login-events.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-31T03:00:00.000Z');
|
||||
|
||||
describe('LoginEventsService.recordSignIn (웹메일 · 앱 로그인 기록 — 계약 5절)', () => {
|
||||
let events: { insert: jest.Mock };
|
||||
let directory: { hasEmail: jest.Mock };
|
||||
let service: LoginEventsService;
|
||||
let errors: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
events = { insert: jest.fn().mockResolvedValue(undefined) };
|
||||
directory = { hasEmail: jest.fn().mockResolvedValue(true) };
|
||||
service = new LoginEventsService(
|
||||
events as unknown as Repository<LoginEventEntity>,
|
||||
directory as unknown as DirectoryService,
|
||||
);
|
||||
errors = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const attempt = (
|
||||
overrides: Partial<Parameters<LoginEventsService['recordSignIn']>[0]>,
|
||||
) => ({
|
||||
method: 'webmail' as const,
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
failureReason: 'wrongPassword' as const,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/141.0',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => {
|
||||
await service.recordSignIn(
|
||||
attempt({
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
userAgent: `UA ${'x'.repeat(600)}`,
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
await service.recordSignIn(attempt({ failureReason: 'wrongCode' }), NOW);
|
||||
|
||||
expect(directory.hasEmail).not.toHaveBeenCalled();
|
||||
expect(events.insert).toHaveBeenCalledTimes(2);
|
||||
const [first] = events.insert.mock.calls[0] as [LoginEventEntity];
|
||||
expect(first).toMatchObject({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'webmail',
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
dedupeKey: null,
|
||||
});
|
||||
expect(first.userAgent).toHaveLength(512);
|
||||
});
|
||||
|
||||
it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => {
|
||||
directory.hasEmail.mockResolvedValueOnce(false);
|
||||
await service.recordSignIn(
|
||||
attempt({ email: 'nobody@arasystem.test' }),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).not.toHaveBeenCalled();
|
||||
|
||||
directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
await service.recordSignIn(attempt({}), NOW);
|
||||
expect(events.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => {
|
||||
events.insert.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.recordSignIn(
|
||||
attempt({ isSuccess: true, failureReason: null }),
|
||||
NOW,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(errors.join('\n')).toMatch(/\[SYS_001\].*a\*\*\*@arasystem\.test/);
|
||||
expect(errors.join('\n')).not.toContain(ARA);
|
||||
});
|
||||
|
||||
it('앱 로그인은 방식 app 으로 남긴다 — User-Agent 원문도 둔다 (계약 1절 "앱 로그인")', async () => {
|
||||
await service.recordSignIn(
|
||||
attempt({
|
||||
method: 'app',
|
||||
isSuccess: true,
|
||||
failureReason: null,
|
||||
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
isSuccess: true,
|
||||
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('보존 기간은 90일', () => {
|
||||
expect(retentionStart(NOW)).toEqual(new Date('2026-08-02T03:00:00.000Z'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginEventsService.insertEngineEvents (엔진 이벤트 넣기 — 문장마다 1,000행)', () => {
|
||||
/** createQueryBuilder().insert().into().values().orIgnore().returning().execute() 대역 — 문장마다 넣은 행을 모은다 */
|
||||
function fakeEvents(existing: ReadonlySet<string> = new Set()) {
|
||||
const statements: { dedupeKey: string | null }[][] = [];
|
||||
const builder = {
|
||||
rows: [] as { dedupeKey: string | null }[],
|
||||
insert: () => builder,
|
||||
into: () => builder,
|
||||
values: (rows: { dedupeKey: string | null }[]) => {
|
||||
builder.rows = rows;
|
||||
return builder;
|
||||
},
|
||||
orIgnore: () => builder,
|
||||
returning: () => builder,
|
||||
execute: () => {
|
||||
statements.push(builder.rows);
|
||||
// ON CONFLICT DO NOTHING — 이미 있는 키는 돌려주지 않는다
|
||||
return Promise.resolve({
|
||||
raw: builder.rows
|
||||
.filter((row) => row.dedupeKey && !existing.has(row.dedupeKey))
|
||||
.map((row) => ({ dedupe_key: row.dedupeKey })),
|
||||
});
|
||||
},
|
||||
};
|
||||
return {
|
||||
statements,
|
||||
repository: { createQueryBuilder: () => builder },
|
||||
};
|
||||
}
|
||||
|
||||
const rowsOf = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
userEmail: ARA,
|
||||
occurredAt: NOW,
|
||||
method: 'imap' as const,
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '211.186.95.252',
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: `key-${index}`,
|
||||
}));
|
||||
|
||||
it(`한 문장에 ${LOGIN_EVENT_INSERT_CHUNK}행까지 — 6,000행(행마다 11칸이면 한 문장의 매개변수 상한 65,535 를 넘는다)은 6문장으로 나눠 넣고 새 키를 모두 돌려준다`, async () => {
|
||||
const { statements, repository } = fakeEvents(
|
||||
new Set(['key-10', 'key-5999']),
|
||||
);
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
|
||||
const inserted = await service.insertEngineEvents(rowsOf(6_000));
|
||||
|
||||
expect(statements.map((rows) => rows.length)).toEqual([
|
||||
1000, 1000, 1000, 1000, 1000, 1000,
|
||||
]);
|
||||
expect(
|
||||
Math.max(...statements.map((rows) => rows.length * 11)),
|
||||
).toBeLessThan(65_535);
|
||||
expect(inserted.size).toBe(5_998);
|
||||
expect(inserted.has('key-10')).toBe(false);
|
||||
expect(inserted.has('key-1000')).toBe(true);
|
||||
});
|
||||
|
||||
it('남은 행만큼 마지막 문장 · 빈 묶음은 DB 에 묻지 않는다', async () => {
|
||||
const { statements, repository } = fakeEvents();
|
||||
const service = new LoginEventsService(
|
||||
repository as unknown as Repository<LoginEventEntity>,
|
||||
{} as DirectoryService,
|
||||
);
|
||||
await service.insertEngineEvents(rowsOf(2_500));
|
||||
expect(statements.map((rows) => rows.length)).toEqual([1000, 1000, 500]);
|
||||
|
||||
statements.length = 0;
|
||||
await expect(service.insertEngineEvents([])).resolves.toEqual(new Set());
|
||||
expect(statements).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, type Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
import { normalizeEmail } from '@/modules/auth/employee-address';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
import {
|
||||
LOGIN_EVENT_INSERT_CHUNK,
|
||||
LOGIN_EVENT_RETENTION_DAYS,
|
||||
} from './account-security.constants';
|
||||
import {
|
||||
LoginEventEntity,
|
||||
type LoginFailureReason,
|
||||
type SignInMethod,
|
||||
} from './login-event.entity';
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
encodeLoginEventCursor,
|
||||
loginEventScope,
|
||||
} from './login-event-cursor';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 웹메일 · 앱 로그인 시도 하나 — 서버가 판정한 것 */
|
||||
export interface SignInAttempt {
|
||||
/** webmail(웹 — 쿠키) · app(모바일 앱 — 계약 1절 "앱 로그인") */
|
||||
readonly method: SignInMethod;
|
||||
readonly email: string;
|
||||
readonly isSuccess: boolean;
|
||||
/** 성공이 코드까지 거쳤는가 */
|
||||
readonly isTwoFactor: boolean;
|
||||
readonly failureReason: LoginFailureReason | null;
|
||||
readonly ip: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
/** 로그인 기록 조회 — 커서 · 개수 · 성공만/실패만 */
|
||||
export interface LoginEventQuery {
|
||||
readonly cursor?: string;
|
||||
readonly limit: number;
|
||||
readonly isSuccess?: boolean;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** 보존 기간의 시작 — 지금부터 90일 전 */
|
||||
export function retentionStart(now: Date): Date {
|
||||
return new Date(now.getTime() - LOGIN_EVENT_RETENTION_DAYS * DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
|
||||
*
|
||||
* - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도
|
||||
* 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다
|
||||
* - 기록에 실패해도 로그인은 그대로 간다 (error 로그만)
|
||||
* - 목록은 최근 90일 · 최근 차례 · 커서(자리) — 성공만 · 실패만으로 거를 수 있다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 기록에 없다. User-Agent 원문은 512자까지 둔다(화면에는 짧은 이름만)
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginEventsService {
|
||||
private readonly logger = new Logger(LoginEventsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(LoginEventEntity)
|
||||
private readonly events: Repository<LoginEventEntity>,
|
||||
private readonly directory: DirectoryService,
|
||||
) {}
|
||||
|
||||
/** 웹메일 · 앱 로그인 시도를 남긴다 — 던지지 않는다 */
|
||||
async recordSignIn(
|
||||
attempt: SignInAttempt,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(attempt.email);
|
||||
try {
|
||||
const keep =
|
||||
attempt.isSuccess ||
|
||||
attempt.failureReason === 'wrongCode' ||
|
||||
(await this.isKnownEmployee(address));
|
||||
if (!keep) return;
|
||||
await this.events.insert({
|
||||
userEmail: address,
|
||||
occurredAt: now,
|
||||
method: attempt.method,
|
||||
isSuccess: attempt.isSuccess,
|
||||
isTwoFactor: attempt.isTwoFactor,
|
||||
failureReason: attempt.failureReason,
|
||||
ip: attempt.ip,
|
||||
userAgent: truncateUserAgent(attempt.userAgent),
|
||||
appPasswordId: null,
|
||||
appPasswordName: null,
|
||||
dedupeKey: null,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} ${attempt.method === 'app' ? '앱' : '웹메일'} 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 이벤트의 기록들을 넣는다 — 같은 겹침 거르기 키가 이미 있으면 건너뛴다(ON CONFLICT DO NOTHING) → 새로 넣은 키.
|
||||
* 한 문장의 매개변수 상한 때문에 1,000행씩 나눠 넣는다(LOGIN_EVENT_INSERT_CHUNK) — 문장마다 따로 끝나도 다시 받으면 키가 같아 겹치지 않는다.
|
||||
* DB 실패는 던진다 — 웹훅이 5xx 로 답해 엔진이 다시 보낸다
|
||||
*/
|
||||
async insertEngineEvents(
|
||||
rows: readonly Omit<LoginEventEntity, 'id'>[],
|
||||
): Promise<Set<string>> {
|
||||
const inserted = new Set<string>();
|
||||
for (
|
||||
let start = 0;
|
||||
start < rows.length;
|
||||
start += LOGIN_EVENT_INSERT_CHUNK
|
||||
) {
|
||||
const result = await this.events
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(LoginEventEntity)
|
||||
.values(rows.slice(start, start + LOGIN_EVENT_INSERT_CHUNK))
|
||||
.orIgnore()
|
||||
.returning(['dedupe_key'])
|
||||
.execute();
|
||||
const raw: unknown = result.raw;
|
||||
if (!Array.isArray(raw)) continue;
|
||||
for (const row of raw as { dedupe_key?: unknown }[]) {
|
||||
if (typeof row.dedupe_key === 'string') inserted.add(row.dedupe_key);
|
||||
}
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/** 실패를 남길 주소인가 — 사내 목록에 있는 주소. 사내 목록을 읽지 못하면 남긴다 */
|
||||
async isKnownEmployee(address: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.directory.hasEmail(address);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/login-events — 최근 90일 · 최근 차례 */
|
||||
async list(
|
||||
user: string,
|
||||
query: LoginEventQuery,
|
||||
now: Date = new Date(),
|
||||
): Promise<PaginatedResult<LoginEventEntity>> {
|
||||
const address = normalizeEmail(user);
|
||||
const scope = loginEventScope(query.isSuccess);
|
||||
const position = query.cursor ? this.readCursor(query.cursor, scope) : null;
|
||||
|
||||
const base = this.events
|
||||
.createQueryBuilder('event')
|
||||
.where('event.user_email = :address', { address })
|
||||
.andWhere('event.occurred_at >= :since', { since: retentionStart(now) });
|
||||
if (query.isSuccess !== undefined) {
|
||||
base.andWhere('event.is_success = :isSuccess', {
|
||||
isSuccess: query.isSuccess,
|
||||
});
|
||||
}
|
||||
const total = await base.clone().getCount();
|
||||
|
||||
const page = base
|
||||
.clone()
|
||||
.orderBy('event.occurred_at', 'DESC')
|
||||
.addOrderBy('event.id', 'DESC')
|
||||
.limit(query.limit + 1);
|
||||
if (position) {
|
||||
page.andWhere(
|
||||
'(event.occurred_at < :at OR (event.occurred_at = :at AND event.id < :id))',
|
||||
{ at: position.occurredAt, id: position.id },
|
||||
);
|
||||
}
|
||||
const rows = await page.getMany();
|
||||
const hasNext = rows.length > query.limit;
|
||||
const items = hasNext ? rows.slice(0, query.limit) : rows;
|
||||
const last = items[items.length - 1];
|
||||
return toPaginatedResult(items, {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last
|
||||
? encodeLoginEventCursor(
|
||||
{ occurredAt: last.occurredAt, id: last.id },
|
||||
scope,
|
||||
)
|
||||
: null,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
/** 90일이 지난 기록을 지운다 → 지운 수 */
|
||||
async purge(now: Date = new Date()): Promise<number> {
|
||||
const result = await this.events.delete({
|
||||
occurredAt: LessThan(retentionStart(now)),
|
||||
});
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
private readCursor(cursor: string, scope: string) {
|
||||
const reading = decodeLoginEventCursor(cursor, scope);
|
||||
if ('problem' in reading) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: `invalid-login-event-cursor:${reading.problem}` },
|
||||
});
|
||||
}
|
||||
return reading.position;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
addressOfAccountName,
|
||||
engineEventFingerprint,
|
||||
failureReasonOf,
|
||||
methodOfListener,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
const RECEIVED = new Date('2026-10-31T00:00:10.000Z');
|
||||
|
||||
/** 엔진이 보낸 모양 그대로 (2026-10-31 개발 엔진 웹훅 — 값은 바꿈) */
|
||||
const imapSuccess = {
|
||||
id: '17619051050001auth.success',
|
||||
createdAt: '2026-10-31T00:00:05Z',
|
||||
type: 'auth.success',
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
localPort: 993,
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
accountId: 4,
|
||||
spanId: 12345,
|
||||
},
|
||||
};
|
||||
|
||||
describe('readEngineEvents (엔진 웹훅 본문 읽기)', () => {
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 읽는다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
imapSuccess,
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'submissions',
|
||||
id: 'a1',
|
||||
reason: 'Authentication failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
...imapSuccess,
|
||||
type: 'auth.mfa-required',
|
||||
data: { ...imapSuccess.data, listenerId: 'smtp' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
|
||||
expect(reading.malformed).toBe(false);
|
||||
expect(reading.skipped).toBe(0);
|
||||
expect(
|
||||
reading.events.map(({ method, outcome }) => [method, outcome]),
|
||||
).toEqual([
|
||||
['imap', 'success'],
|
||||
['smtp', 'failed'],
|
||||
['smtp', 'mfa-required'],
|
||||
]);
|
||||
expect(reading.events[0]).toMatchObject({
|
||||
occurredAt: new Date('2026-10-31T00:00:05Z'),
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
remoteIp: '211.186.95.252',
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('빈 묶음 {"events":[]} 은 정상이다 (setup 이 서명한 빈 묶음으로 받는 쪽을 확인한다)', () => {
|
||||
expect(readEngineEvents({ events: [] }, RECEIVED)).toEqual({
|
||||
events: [],
|
||||
skipped: 0,
|
||||
malformed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 자격 번호를 앱 비밀번호 id 로 — details 가 앱 비밀번호 표시일 때만 (IMAP 실패의 id 는 명령 태그다)', () => {
|
||||
const appPassword = {
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
},
|
||||
};
|
||||
const failedWithTag = {
|
||||
...imapSuccess,
|
||||
type: 'auth.failed',
|
||||
data: { ...imapSuccess.data, id: 'a1' },
|
||||
};
|
||||
const [success, failed] = readEngineEvents(
|
||||
{ events: [appPassword, failedWithTag] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
|
||||
expect(success).toMatchObject({
|
||||
accountName: 'doyun.lee',
|
||||
appPasswordId: 'b',
|
||||
});
|
||||
expect(failed.appPasswordId).toBeNull();
|
||||
});
|
||||
|
||||
it('다른 종류 · http 리스너(웹메일 — 서버가 따로 남긴다) · 모양이 틀린 이벤트는 버린다', () => {
|
||||
const reading = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, type: 'auth.too-many-attempts' },
|
||||
{
|
||||
...imapSuccess,
|
||||
data: {
|
||||
...imapSuccess.data,
|
||||
listenerId: 'http',
|
||||
details: 'mail-gateway@arasystem.test',
|
||||
},
|
||||
},
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, accountName: '' } },
|
||||
{ ...imapSuccess, data: 'oops' },
|
||||
'not-an-object',
|
||||
null,
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
);
|
||||
expect(reading.events).toEqual([]);
|
||||
expect(reading.skipped).toBe(6);
|
||||
});
|
||||
|
||||
it('본문이 {"events":[…]} 모양이 아니면 malformed', () => {
|
||||
for (const body of [null, [], {}, { events: 'x' }, 'text']) {
|
||||
expect(readEngineEvents(body, RECEIVED).malformed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('createdAt 을 읽지 못하면 받은 때로 · IPv4 를 담은 IPv6 는 IPv4 로 · IP 가 아니면 null', () => {
|
||||
const [event] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
createdAt: 'yesterday',
|
||||
data: { ...imapSuccess.data, remoteIp: '::ffff:211.186.95.252' },
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(event.occurredAt).toEqual(RECEIVED);
|
||||
expect(event.remoteIp).toBe('211.186.95.252');
|
||||
|
||||
const [noIp] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{ ...imapSuccess, data: { ...imapSuccess.data, remoteIp: 'nope' } },
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(noIp.remoteIp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineEventFingerprint (겹침 거르기 — 엔진은 다시 보낼 때 id 를 새로 만든다)', () => {
|
||||
it('엔진 이벤트 id 가 달라도 내용이 같으면 같은 지문 — 키 차례는 상관없다', () => {
|
||||
const [first] = readEngineEvents(
|
||||
{ events: [imapSuccess] },
|
||||
RECEIVED,
|
||||
).events;
|
||||
const [retried] = readEngineEvents(
|
||||
{
|
||||
events: [
|
||||
{
|
||||
...imapSuccess,
|
||||
id: '17619051050099auth.success',
|
||||
data: Object.fromEntries(
|
||||
Object.entries(imapSuccess.data).reverse(),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
RECEIVED,
|
||||
).events;
|
||||
expect(retried.dedupeKey).toBe(first.dedupeKey);
|
||||
expect(first.dedupeKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
|
||||
it('내용이 다르면(다른 연결의 spanId · 다른 때) 다른 지문', () => {
|
||||
const base = engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:05Z',
|
||||
imapSuccess.data,
|
||||
);
|
||||
expect(
|
||||
engineEventFingerprint('auth.success', '2026-10-31T00:00:05Z', {
|
||||
...imapSuccess.data,
|
||||
spanId: 99,
|
||||
}),
|
||||
).not.toBe(base);
|
||||
expect(
|
||||
engineEventFingerprint(
|
||||
'auth.success',
|
||||
'2026-10-31T00:00:06Z',
|
||||
imapSuccess.data,
|
||||
),
|
||||
).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('작은 판정', () => {
|
||||
it('리스너 → 방식', () => {
|
||||
expect(methodOfListener('imaps')).toBe('imap');
|
||||
expect(methodOfListener('imap')).toBe('imap');
|
||||
expect(methodOfListener('submissions')).toBe('smtp');
|
||||
expect(methodOfListener('submission')).toBe('smtp');
|
||||
expect(methodOfListener('smtp')).toBe('smtp');
|
||||
expect(methodOfListener('http')).toBeNull();
|
||||
expect(methodOfListener('pop3s')).toBeNull();
|
||||
expect(methodOfListener(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('계정 이름 → 주소 (앱 비밀번호의 로컬 이름에는 사내 도메인을 붙인다)', () => {
|
||||
expect(addressOfAccountName('doyun.lee', 'arasystem.test')).toBe(
|
||||
'doyun.lee@arasystem.test',
|
||||
);
|
||||
expect(
|
||||
addressOfAccountName(' Doyun.Lee@AraSystem.test ', 'arasystem.test'),
|
||||
).toBe('doyun.lee@arasystem.test');
|
||||
expect(addressOfAccountName('', 'arasystem.test')).toBeNull();
|
||||
});
|
||||
|
||||
it('결과 → 실패 까닭', () => {
|
||||
expect(failureReasonOf('success')).toBeNull();
|
||||
expect(failureReasonOf('failed')).toBe('wrongPassword');
|
||||
expect(failureReasonOf('mfa-required')).toBe('appPasswordRequired');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { isIP, isIPv4 } from 'net';
|
||||
|
||||
import { credentialNumberToId } from './app-password';
|
||||
import type {
|
||||
LoginEventMethod,
|
||||
LoginFailureReason,
|
||||
SignInMethod,
|
||||
} from './login-event.entity';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 본문 읽기 (순수 함수) — 계약 5절 "엔진 웹훅 받기".
|
||||
* 본문은 `{"events":[{ "id", "createdAt", "type", "data": {…} }]}` 이다 (Stalwart 0.16.27 — `trc::serializers::json`).
|
||||
* 쓰는 것: type(auth.success · auth.failed · auth.mfa-required), data 의 listenerId · remoteIp · accountName · details · id.
|
||||
*
|
||||
* 겹침 거르기는 엔진 이벤트 id 가 아니라 **이벤트 내용의 지문**으로 한다 — 엔진은 id 를 보낼 때마다 새로 만든다
|
||||
* (`타임스탬프 + 프로세스 카운터 + 종류` 를 직렬화할 때 붙인다 → 실패해 다시 보낸 묶음은 같은 이벤트도 id 가 다르다, 0.16.27 소스 확인).
|
||||
* 내용(type · createdAt · data 전체 — 세션의 spanId · remotePort 가 든다)은 다시 보내도 같다.
|
||||
*/
|
||||
|
||||
/** 엔진의 앱 비밀번호 로그인 표시 (`details`) — Stalwart `auth/authentication.rs` 의 "Authenticated with app password" */
|
||||
const APP_PASSWORD_DETAILS = /\bapp password\b/i;
|
||||
/** 남기는 이벤트 종류 → 결과 */
|
||||
const OUTCOMES: ReadonlyMap<string, EngineLoginOutcome> = new Map([
|
||||
['auth.success', 'success'],
|
||||
['auth.failed', 'failed'],
|
||||
['auth.mfa-required', 'mfa-required'],
|
||||
]);
|
||||
/** IP 문자열 상한 (표의 열 길이) */
|
||||
const MAX_IP_LENGTH = 64;
|
||||
/** 계정 이름 상한 — 주소 최대 길이 (그보다 길면 사내 주소가 아니다) */
|
||||
const MAX_ACCOUNT_NAME_LENGTH = 254;
|
||||
const IPV4_MAPPED_PREFIX = '::ffff:';
|
||||
|
||||
export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required';
|
||||
|
||||
/** 남길 만한 엔진 로그인 이벤트 하나 */
|
||||
export interface EngineLoginEvent {
|
||||
readonly dedupeKey: string;
|
||||
readonly occurredAt: Date;
|
||||
/** 메일 프로그램의 방식 — imap · smtp (웹메일 · 앱은 서버가 판정해 남긴다) */
|
||||
readonly method: Exclude<LoginEventMethod, SignInMethod>;
|
||||
readonly outcome: EngineLoginOutcome;
|
||||
/** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */
|
||||
readonly accountName: string;
|
||||
readonly remoteIp: string | null;
|
||||
/** 앱 비밀번호로 들어온 성공이면 그 id — `details` 가 앱 비밀번호 표시일 때만 (IMAP 실패의 `id` 는 명령 태그다) */
|
||||
readonly appPasswordId: string | null;
|
||||
}
|
||||
|
||||
export interface EngineEventsReading {
|
||||
readonly events: EngineLoginEvent[];
|
||||
/** 버린 수 — 다른 종류 · 다른 리스너(http 등) · 모양이 틀림 */
|
||||
readonly skipped: number;
|
||||
/** 본문이 `{events: [...]}` 모양이 아니다 — 엔진 형식이 바뀌었을 수 있다 */
|
||||
readonly malformed: boolean;
|
||||
}
|
||||
|
||||
/** 결과 → 실패 까닭 (성공이면 null) */
|
||||
export function failureReasonOf(
|
||||
outcome: EngineLoginOutcome,
|
||||
): LoginFailureReason | null {
|
||||
if (outcome === 'failed') return 'wrongPassword';
|
||||
if (outcome === 'mfa-required') return 'appPasswordRequired';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 리스너 → 방식 — imap… → imap, submission… · smtp… → smtp, 그 밖(http 등 — 웹메일은 서버가 따로 남긴다)은 null */
|
||||
export function methodOfListener(
|
||||
listenerId: unknown,
|
||||
): EngineLoginEvent['method'] | null {
|
||||
if (typeof listenerId !== 'string') return null;
|
||||
const id = listenerId.toLowerCase();
|
||||
if (id.startsWith('imap')) return 'imap';
|
||||
if (id.startsWith('submission') || id.startsWith('smtp')) return 'smtp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 엔진의 계정 이름 → 주소 (로컬 이름이면 사내 도메인을 붙인다) — 소문자, 모양이 아니면 null. 직원인지는 부르는 쪽이 본다 */
|
||||
export function addressOfAccountName(
|
||||
accountName: string,
|
||||
domain: string,
|
||||
): string | null {
|
||||
const name = accountName.trim().toLowerCase();
|
||||
if (name === '' || name.length > MAX_ACCOUNT_NAME_LENGTH) return null;
|
||||
return name.includes('@') ? name : `${name}@${domain}`;
|
||||
}
|
||||
|
||||
/** 사람이 읽을 IP — IPv4 를 담은 IPv6 는 IPv4 로, IP 가 아니면 null */
|
||||
function normalizedIp(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length > MAX_IP_LENGTH) return null;
|
||||
const trimmed = value.trim();
|
||||
const mapped = trimmed.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)
|
||||
? trimmed.slice(IPV4_MAPPED_PREFIX.length)
|
||||
: null;
|
||||
if (mapped !== null && isIPv4(mapped)) return mapped;
|
||||
return isIP(trimmed) === 0 ? null : trimmed;
|
||||
}
|
||||
|
||||
/** 지문용 정규화 — 객체의 키를 차례로 (값은 그대로). 깊이는 본문 모양 한도가 이미 막았다 */
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
return Object.keys(record)
|
||||
.sort()
|
||||
.map((key) => [key, canonical(record[key])]);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 이벤트 내용의 지문 — 종류 · 일어난 때(글 그대로) · data 전체 */
|
||||
export function engineEventFingerprint(
|
||||
type: string,
|
||||
createdAt: unknown,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([type, createdAt ?? null, canonical(data)]))
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** 웹훅 본문 → 남길 만한 로그인 이벤트 (사내 직원인지 · 사내 목록에 있는지는 부르는 쪽이 본다) */
|
||||
export function readEngineEvents(
|
||||
body: unknown,
|
||||
receivedAt: Date,
|
||||
): EngineEventsReading {
|
||||
const list = isRecord(body) ? body.events : undefined;
|
||||
if (!Array.isArray(list)) {
|
||||
return { events: [], skipped: 0, malformed: true };
|
||||
}
|
||||
|
||||
const events: EngineLoginEvent[] = [];
|
||||
let skipped = 0;
|
||||
for (const item of list as unknown[]) {
|
||||
const event = readEvent(item, receivedAt);
|
||||
if (event) events.push(event);
|
||||
else skipped += 1;
|
||||
}
|
||||
return { events, skipped, malformed: false };
|
||||
}
|
||||
|
||||
function readEvent(item: unknown, receivedAt: Date): EngineLoginEvent | null {
|
||||
if (!isRecord(item) || typeof item.type !== 'string') return null;
|
||||
const outcome = OUTCOMES.get(item.type);
|
||||
const data = isRecord(item.data) ? item.data : null;
|
||||
if (!outcome || !data) return null;
|
||||
|
||||
const method = methodOfListener(data.listenerId);
|
||||
if (method === null) return null;
|
||||
if (typeof data.accountName !== 'string') return null;
|
||||
const accountName = data.accountName.trim().toLowerCase();
|
||||
if (accountName === '' || accountName.length > MAX_ACCOUNT_NAME_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const created =
|
||||
typeof item.createdAt === 'string' ? new Date(item.createdAt) : null;
|
||||
const occurredAt =
|
||||
created && !Number.isNaN(created.getTime()) ? created : receivedAt;
|
||||
const isAppPassword =
|
||||
outcome === 'success' &&
|
||||
typeof data.details === 'string' &&
|
||||
APP_PASSWORD_DETAILS.test(data.details);
|
||||
|
||||
return {
|
||||
dedupeKey: engineEventFingerprint(item.type, item.createdAt, data),
|
||||
occurredAt,
|
||||
method,
|
||||
outcome,
|
||||
accountName,
|
||||
remoteIp: normalizedIp(data.remoteIp),
|
||||
appPasswordId: isAppPassword ? credentialNumberToId(data.id) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { MAIL_ENGINE_EVENTS_ROUTE } from './account-security.constants';
|
||||
import { MailEngineEventsGuard } from './mail-engine-events.guard';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
// 엔진만 부르는 길 (계약 5절 "엔진 웹훅 받기") — 앞단 nginx 가 /api/internal/ 을 404 로 닫고, 본문 파서가 소켓 주소(앱 네트워크) ·
|
||||
// 서명(HMAC-SHA256)을 본문을 읽기 전에 확인한다(mail-engine-events.parser.ts — 아니면 401 AUTH_001). 요청 수 한도가 없다 —
|
||||
// 엔진이 1초에 한 번까지 묶어 보낸다. 출처 검사(OriginGuard)는 엔진 요청에 Origin 이 없어 통과한다
|
||||
@ApiTags('Internal')
|
||||
@SkipThrottle()
|
||||
@UseGuards(MailEngineEventsGuard)
|
||||
@Controller(MAIL_ENGINE_EVENTS_ROUTE)
|
||||
export class MailEngineEventsController {
|
||||
constructor(private readonly engineEvents: MailEngineEventsService) {}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'메일 엔진의 로그인 이벤트 받기 (엔진 웹훅 — 브라우저 · 앱은 부르지 않는다)',
|
||||
description:
|
||||
'본문 {"events":[{id, createdAt, type, data}]} — auth.success · auth.failed · auth.mfa-required 의 IMAP · SMTP 이벤트만 로그인 기록에 남긴다. ' +
|
||||
'사내 직원만(사내 목록에 없는 주소의 실패 · 막은 계정은 버린다), 겹침은 이벤트 내용의 지문으로 거른다. ' +
|
||||
'본문 4MB · 배열 하나 10,000 · 값 200,000 까지. DB 에 남기지 못하면 5xx — 엔진이 다시 보낸다.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Signature',
|
||||
description: '본문 원문의 HMAC-SHA256 (MAIL_WEBHOOK_SECRET), base64',
|
||||
required: true,
|
||||
})
|
||||
@ApiBody({
|
||||
description: '엔진 웹훅 본문',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
type: { type: 'string', example: 'auth.success' },
|
||||
data: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '받았다 (data: null)' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'앱 네트워크 밖의 접속 · 서명이 없거나 맞지 않음 · 서명 키가 설정되지 않음(개발) · JSON 이 아닌 본문',
|
||||
)
|
||||
@ApiErrorResponse('VAL_001', '본문이 JSON 이 아니거나 모양 한도를 넘음')
|
||||
async receive(@Body() body: unknown): Promise<null> {
|
||||
await this.engineEvents.accept(body);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isVerifiedMailEngineEventsRequest } from './mail-engine-events.parser';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 처리기 앞의 가드 — 본문 파서(mail-engine-events.parser.ts)가 접속 주소 · 서명을 확인한 요청만 들인다.
|
||||
* 파서를 건너뛴 요청(JSON 이 아닌 본문 · 빈 본문 등)은 확인을 거치지 않았으므로 401 AUTH_001 — 본문을 쓰지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
if (isVerifiedMailEngineEventsRequest(request)) return true;
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'mail-engine-events-not-verified',
|
||||
remoteAddress: request.socket?.remoteAddress ?? 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import express from 'express';
|
||||
import { createServer, type Server } from 'http';
|
||||
import { connect } from 'net';
|
||||
import request from 'supertest';
|
||||
|
||||
import { parseCidr } from '@/shared/utils/cidr';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
import {
|
||||
MailEngineEventsRejection,
|
||||
MailEngineRejectionLog,
|
||||
createMailEngineEventsBodyParser,
|
||||
hasValidEngineSignature,
|
||||
isMailEngineEventsRequest,
|
||||
isVerifiedMailEngineEventsRequest,
|
||||
} from './mail-engine-events.parser';
|
||||
|
||||
const SECRET = 'Wh7kQ2pLm9Rt4Vz8Kc3NxB6yHs5gJf1a';
|
||||
const sign = (body: string, secret = SECRET) =>
|
||||
createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest('base64');
|
||||
|
||||
describe('엔진 웹훅 서명 (X-Signature — 본문 원문의 HMAC-SHA256, base64)', () => {
|
||||
const body = '{"events":[]}';
|
||||
|
||||
it('같은 키로 서명한 본문이면 true (엔진은 키를 UTF-8 바이트로 쓴다)', () => {
|
||||
expect(hasValidEngineSignature(Buffer.from(body), sign(body), SECRET)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 키 · 바뀐 본문 · 없거나 깨진 서명 · 길이가 다른 서명은 false', () => {
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from(body),
|
||||
sign(body, `${SECRET}x`),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasValidEngineSignature(
|
||||
Buffer.from('{"events":[1]}'),
|
||||
sign(body),
|
||||
SECRET,
|
||||
),
|
||||
).toBe(false);
|
||||
for (const signature of [
|
||||
undefined,
|
||||
'',
|
||||
'not-base64!!',
|
||||
'AAAA',
|
||||
['a', 'b'],
|
||||
]) {
|
||||
expect(
|
||||
hasValidEngineSignature(Buffer.from(body), signature, SECRET),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 경로 판정', () => {
|
||||
it('POST /api/internal/mail-engine-events 만 (쿼리 · 끝 / 무관, 대소문자 변형은 넓게 — 라우터에서 404)', () => {
|
||||
for (const url of [
|
||||
'/api/internal/mail-engine-events',
|
||||
'/api/internal/mail-engine-events/',
|
||||
'/api/internal/mail-engine-events?x=1',
|
||||
'/API/Internal/Mail-Engine-Events',
|
||||
]) {
|
||||
expect(isMailEngineEventsRequest({ method: 'POST', url })).toBe(true);
|
||||
}
|
||||
expect(
|
||||
isMailEngineEventsRequest({
|
||||
method: 'GET',
|
||||
url: '/api/internal/mail-engine-events',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isMailEngineEventsRequest({ method: 'POST', url: '/api/messages' }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 거절의 warn (MailEngineRejectionLog — 같은 까닭은 1분에 한 번)', () => {
|
||||
const rejection = (reason: string) =>
|
||||
new MailEngineEventsRejection(reason, '172.16.71.2');
|
||||
|
||||
it('처음 거절은 바로 남기고, 구간 안의 같은 까닭은 세기만 하다가 구간이 지나면 그 수를 붙여 다시 남긴다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
const secretMissing = rejection('mail-engine-events-secret-missing');
|
||||
|
||||
log.note(secretMissing, 'trace-1', 0);
|
||||
for (let second = 1; second < 60; second += 1) {
|
||||
log.note(secretMissing, `trace-${second}`, second * 1000);
|
||||
}
|
||||
expect(lines).toEqual([
|
||||
'[AUTH_001][trace-1] POST /api/internal/mail-engine-events {"reason":"mail-engine-events-secret-missing","remoteAddress":"172.16.71.2"} (같은 까닭은 60초에 한 번만 남긴다)',
|
||||
]);
|
||||
|
||||
log.note(secretMissing, 'trace-60', 60_000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('[trace-60]');
|
||||
expect(lines[1]).toContain('앞 구간에 같은 까닭으로 59번 더 거절했다');
|
||||
});
|
||||
|
||||
it('까닭이 다르면 따로 센다 — 서명 틀림은 서명 키 없음에 묻히지 않는다', () => {
|
||||
const lines: string[] = [];
|
||||
const log = new MailEngineRejectionLog((line) => lines.push(line), 60_000);
|
||||
log.note(rejection('mail-engine-events-secret-missing'), undefined, 0);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 1000);
|
||||
log.note(rejection('mail-engine-events-bad-signature'), undefined, 2000);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[1]).toContain('mail-engine-events-bad-signature');
|
||||
expect(lines[1]).not.toContain('[undefined]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () => {
|
||||
const PATH = '/api/internal/mail-engine-events';
|
||||
const settings = (
|
||||
overrides: Partial<AccountSecuritySettings> = {},
|
||||
): AccountSecuritySettings => ({
|
||||
webhookSecret: SECRET,
|
||||
appNetwork: parseCidr('127.0.0.0/8')!,
|
||||
isAppNetworkAddress: () => true,
|
||||
...overrides,
|
||||
});
|
||||
const appWith = (config: AccountSecuritySettings, lines: string[]) => {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.headers['x-request-id'] = 'trace-e';
|
||||
next();
|
||||
});
|
||||
app.use(
|
||||
createMailEngineEventsBodyParser(
|
||||
config,
|
||||
new MailEngineRejectionLog((line) => lines.push(line)),
|
||||
),
|
||||
);
|
||||
app.post(PATH, (req, res) => {
|
||||
res.json({
|
||||
verified: isVerifiedMailEngineEventsRequest(req),
|
||||
body: req.body as unknown,
|
||||
});
|
||||
});
|
||||
return app;
|
||||
};
|
||||
const AUTH_001_BODY = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
traceId: 'trace-e',
|
||||
},
|
||||
};
|
||||
|
||||
it('서명이 맞으면 본문을 읽고 표시를 남긴다 — 빈 묶음도 (setup 의 확인)', async () => {
|
||||
const lines: string[] = [];
|
||||
const raw = '{"events":[]}';
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('X-Signature', sign(raw))
|
||||
.set('X-Arasystem-Setup', '[setup.mjs] key=abc')
|
||||
.send(raw)
|
||||
.expect(200);
|
||||
expect(res.body).toEqual({ verified: true, body: { events: [] } });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('서명 키가 없거나 · 서명이 틀리거나 · 앱 네트워크 밖이면 표준 실패 형식의 401 AUTH_001 — warn 은 까닭마다 한 번', async () => {
|
||||
const raw = '{"events":[]}';
|
||||
const cases: [AccountSecuritySettings, string | undefined, string][] = [
|
||||
[settings({ webhookSecret: null }), sign(raw), 'secret-missing'],
|
||||
[settings(), sign(raw, `${SECRET}x`), 'bad-signature'],
|
||||
[settings(), undefined, 'bad-signature'],
|
||||
[
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
sign(raw),
|
||||
'outside-app-network',
|
||||
],
|
||||
];
|
||||
for (const [config, signature, reason] of cases) {
|
||||
const lines: string[] = [];
|
||||
const app = appWith(config, lines);
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const call = request(app)
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'application/json');
|
||||
if (signature !== undefined) call.set('X-Signature', signature);
|
||||
const res = await call.send(raw).expect(401);
|
||||
expect(res.body).toEqual(AUTH_001_BODY);
|
||||
}
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
expect(lines[0]).not.toContain(SECRET);
|
||||
}
|
||||
});
|
||||
|
||||
describe('본문을 받기 전에 — 접속 주소 · 서명 키 (2026-10-31 리뷰: 앱 네트워크 밖도 4MB 까지 받았다)', () => {
|
||||
/** 머리글만 보내고(본문은 보내지 않는다) 응답을 기다린다 — 본문을 기다리는 파서라면 답하지 않는다 */
|
||||
async function headersOnly(
|
||||
config: AccountSecuritySettings,
|
||||
lines: string[],
|
||||
): Promise<string> {
|
||||
const server: Server = createServer(appWith(config, lines));
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
try {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const socket = connect(port, '127.0.0.1');
|
||||
let text = '';
|
||||
// 답하지 않는 것을 보는 기한 — 일찍 답하는 경로는 곧바로 끝나므로 이 기한에 기대지 않는다
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
resolve('NO-ANSWER');
|
||||
}, 1_000);
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
text += chunk.toString('utf8');
|
||||
});
|
||||
socket.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
resolve(text);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
socket.write(
|
||||
`POST ${PATH} HTTP/1.1\r\nHost: backend\r\nContent-Type: application/json\r\n` +
|
||||
`X-Signature: ${sign('{"events":[]}')}\r\nContent-Length: 3000000\r\n\r\n`,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'앱 네트워크 밖',
|
||||
settings({ isAppNetworkAddress: () => false }),
|
||||
'outside-app-network',
|
||||
],
|
||||
[
|
||||
'서명 키 없음(개발)',
|
||||
settings({ webhookSecret: null }),
|
||||
'secret-missing',
|
||||
],
|
||||
])(
|
||||
'%s — 본문을 기다리지 않고 바로 401 AUTH_001 을 쓰고 연결을 닫는다',
|
||||
async (_label, config, reason) => {
|
||||
const lines: string[] = [];
|
||||
const answer = await headersOnly(config, lines);
|
||||
|
||||
expect(answer).toMatch(/^HTTP\/1\.1 401 /);
|
||||
expect(answer.toLowerCase()).toContain('connection: close');
|
||||
expect(answer).toContain('"code":"AUTH_001"');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toContain(`mail-engine-events-${reason}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('주소 · 서명 키가 맞으면 본문을 받는다 — 서명은 원문을 다 받은 뒤에 본다(본문이 오지 않으면 답하지 않는다)', async () => {
|
||||
const lines: string[] = [];
|
||||
await expect(headersOnly(settings(), lines)).resolves.toBe('NO-ANSWER');
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON 이 아닌 본문은 읽지 않고 지나간다 — 처리기 앞의 가드가 표시 없는 요청을 막는다', async () => {
|
||||
const lines: string[] = [];
|
||||
const res = await request(appWith(settings(), lines))
|
||||
.post(PATH)
|
||||
.set('Content-Type', 'text/plain')
|
||||
.send('{"events":[]}')
|
||||
.expect(200);
|
||||
expect(res.body).toMatchObject({ verified: false });
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { HttpStatus, Logger } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { json, type RequestHandler } from 'express';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { getErrorDefinition } from '@/common/errors/error-codes';
|
||||
import { REQUEST_ID_HEADER } from '@/common/middleware/request-id.middleware';
|
||||
import { findJsonTextShapeProblem } from '@/shared/utils/json-shape';
|
||||
import {
|
||||
MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
MAIL_ENGINE_EVENTS_ROUTE,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
MAIL_ENGINE_SIGNATURE_HEADER,
|
||||
} from './account-security.constants';
|
||||
import type { AccountSecuritySettings } from './account-security.settings';
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 본문 파서 (계약 5절 "엔진 웹훅 받기") — 서버 몫의 두 겹:
|
||||
* 1. 접속 주소 — `X-Forwarded-For` 가 아니라 소켓 주소가 앱 네트워크(`APP_NETWORK_SUBNET`) 안이어야 한다.
|
||||
* 서명 키가 없는지(개발)와 함께 **본문을 받기 전에** 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다
|
||||
* (앱 네트워크 밖의 접속이 4MB 까지 보내게 두지 않는다 — 2026-10-31 리뷰)
|
||||
* 2. 서명 — 본문 원문의 HMAC-SHA256(`X-Signature`, base64)이 `MAIL_WEBHOOK_SECRET` 으로 맞아야 한다 (상수 시간 비교).
|
||||
* 원문이 있어야 하므로 본문을 받은 뒤 · JSON 으로 읽기 전에(body-parser 의 verify) 본다
|
||||
* 하나라도 아니면 401 AUTH_001(표준 실패 형식)을 이 파서가 바로 쓴다. warn 에는 까닭과 접속 주소만 남기고,
|
||||
* 같은 까닭은 1분에 한 번만 남긴다(MailEngineRejectionLog — 서명 키가 어긋나면 엔진이 1초마다 보낸다).
|
||||
* (세 번째 겹 — 앞단 nginx 가 /api/internal/ 을 404 로 닫는 것은 release-ops 몫이다)
|
||||
*
|
||||
* 이 경로만 JSON 한도를 따로 둔다(본문 4MB · 배열 하나 10,000 · 값 200,000 — 공격이 오면 엔진의 1초 묶음이 커진다).
|
||||
* 통과한 요청에는 표시를 남긴다 — 처리기 앞의 가드(MailEngineEventsGuard)가 표시 없는 요청(JSON 이 아니라 이 파서를 건너뛴 본문 등)을 막는다.
|
||||
* 서명 키 · 본문은 어떤 로그에도 남기지 않는다.
|
||||
*/
|
||||
|
||||
/** 이 파서를 지난(주소 · 서명이 맞은) 요청의 표시 */
|
||||
const VERIFIED = Symbol('mail-engine-events-verified');
|
||||
|
||||
type MarkedRequest = IncomingMessage & { [VERIFIED]?: true };
|
||||
|
||||
const MAIL_ENGINE_EVENTS_PATH = `/api/${MAIL_ENGINE_EVENTS_ROUTE}`;
|
||||
/** UTF-8 의 비교용 이름 — app.setup.ts 의 verifyJsonBody 와 같은 정규화 (body-parser 가 UTF-8 로 읽는 이름들) */
|
||||
const UTF8_CHARSET = 'utf8';
|
||||
/** SHA-256 의 길이(바이트) */
|
||||
const SHA256_BYTES = 32;
|
||||
|
||||
/** 엔진 웹훅 요청인가 — POST /api/internal/mail-engine-events (쿼리 · 끝 `/` 는 가리지 않고, 대소문자는 넓게 — 변형은 라우터에서 404) */
|
||||
export function isMailEngineEventsRequest(
|
||||
req: Pick<IncomingMessage, 'method' | 'url'>,
|
||||
): boolean {
|
||||
if (req.method !== 'POST') return false;
|
||||
const path = (req.url ?? '').split('?')[0].replace(/\/+$/, '').toLowerCase();
|
||||
return path === MAIL_ENGINE_EVENTS_PATH;
|
||||
}
|
||||
|
||||
/** 파서가 주소 · 서명을 확인한 요청인가 (가드가 본다) */
|
||||
export function isVerifiedMailEngineEventsRequest(req: object): boolean {
|
||||
return (req as MarkedRequest)[VERIFIED] === true;
|
||||
}
|
||||
|
||||
/** 주소 · 서명 확인에서 거절한 요청 — 파서가 401 AUTH_001 을 바로 쓴다 (전역 필터로 보내지 않는다) */
|
||||
export class MailEngineEventsRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: string,
|
||||
readonly remoteAddress: string,
|
||||
) {
|
||||
super(reason);
|
||||
this.name = 'MailEngineEventsRejection';
|
||||
}
|
||||
}
|
||||
|
||||
function rejected(
|
||||
reason: string,
|
||||
req: IncomingMessage,
|
||||
): MailEngineEventsRejection {
|
||||
return new MailEngineEventsRejection(
|
||||
reason,
|
||||
req.socket?.remoteAddress ?? 'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹훅 거절의 warn — 같은 까닭은 구간(1분)에 한 번만 남기고, 그사이 거절한 수는 다음 줄에 붙인다.
|
||||
* 까닭은 셋뿐이라(앱 네트워크 밖 · 서명 키 없음 · 서명 틀림) 기억이 커지지 않는다. 서명 · 본문은 받지 않는다
|
||||
*/
|
||||
export class MailEngineRejectionLog {
|
||||
private readonly windows = new Map<
|
||||
string,
|
||||
{ endsAt: number; suppressed: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly write: (message: string) => void,
|
||||
private readonly windowMs: number = MAIL_ENGINE_REJECTION_LOG_WINDOW_MS,
|
||||
) {}
|
||||
|
||||
note(
|
||||
rejection: MailEngineEventsRejection,
|
||||
traceId: string | undefined,
|
||||
now: number = Date.now(),
|
||||
): void {
|
||||
const current = this.windows.get(rejection.reason);
|
||||
if (current && now < current.endsAt) {
|
||||
current.suppressed += 1;
|
||||
return;
|
||||
}
|
||||
this.windows.set(rejection.reason, {
|
||||
endsAt: now + this.windowMs,
|
||||
suppressed: 0,
|
||||
});
|
||||
const context = JSON.stringify({
|
||||
reason: rejection.reason,
|
||||
remoteAddress: rejection.remoteAddress,
|
||||
});
|
||||
const earlier =
|
||||
current && current.suppressed > 0
|
||||
? ` — 앞 구간에 같은 까닭으로 ${current.suppressed}번 더 거절했다`
|
||||
: '';
|
||||
this.write(
|
||||
`[AUTH_001]${traceId ? `[${traceId}]` : ''} POST ${MAIL_ENGINE_EVENTS_PATH} ${context}${earlier} (같은 까닭은 ${Math.round(this.windowMs / 1000)}초에 한 번만 남긴다)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `X-Signature`(base64) 가 본문의 HMAC-SHA256 과 같은가 — 길이가 다르면 바로 false, 같으면 상수 시간 비교 */
|
||||
export function hasValidEngineSignature(
|
||||
body: Buffer,
|
||||
signature: string | string[] | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (typeof signature !== 'string' || signature === '') return false;
|
||||
const presented = Buffer.from(signature.trim(), 'base64');
|
||||
if (presented.length !== SHA256_BYTES) return false;
|
||||
const expected = createHmac('sha256', Buffer.from(secret, 'utf8'))
|
||||
.update(body)
|
||||
.digest();
|
||||
return timingSafeEqual(expected, presented);
|
||||
}
|
||||
|
||||
/** 본문을 읽기 전의 확인 — body-parser 의 verify (던지면 그 상태 코드로 끝난다 — BusinessException 의 status) */
|
||||
function verifier(settings: AccountSecuritySettings) {
|
||||
return (
|
||||
req: IncomingMessage,
|
||||
_res: ServerResponse,
|
||||
buffer: Buffer,
|
||||
encoding: string,
|
||||
): void => {
|
||||
// 접속 주소 · 서명 키 유무는 본문을 받기 전에 이미 봤다(createMailEngineEventsBodyParser) — 키는 타입을 좁히려고 한 번 더
|
||||
if (settings.webhookSecret === null) {
|
||||
throw rejected('mail-engine-events-secret-missing', req);
|
||||
}
|
||||
if (
|
||||
!hasValidEngineSignature(
|
||||
buffer,
|
||||
req.headers[MAIL_ENGINE_SIGNATURE_HEADER],
|
||||
settings.webhookSecret,
|
||||
)
|
||||
) {
|
||||
throw rejected('mail-engine-events-bad-signature', req);
|
||||
}
|
||||
if (encoding.toLowerCase().replace(/[^0-9a-z]/g, '') !== UTF8_CHARSET) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-not-utf8' },
|
||||
});
|
||||
}
|
||||
const problem = findJsonTextShapeProblem(
|
||||
buffer,
|
||||
MAIL_ENGINE_EVENTS_SHAPE_LIMITS,
|
||||
);
|
||||
if (problem !== null) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'json-shape-limit', problem, stage: 'before-parse' },
|
||||
});
|
||||
}
|
||||
(req as MarkedRequest)[VERIFIED] = true;
|
||||
};
|
||||
}
|
||||
|
||||
const logger = new Logger('MailEngineEvents');
|
||||
|
||||
/**
|
||||
* 엔진 웹훅의 JSON 파서 — configureApp 이 **다른 JSON 파서보다 먼저** 붙인다(이 경로의 본문을 1MB 파서가 413 으로 끊지 않게).
|
||||
* 이 경로의 JSON 본문만 읽는다 — 그 밖의 요청은 건너뛴다.
|
||||
* - 이 경로면 본문을 받기 전에 접속 주소(앱 네트워크) · 서명 키 유무를 본다 — 아니면 본문을 받지 않고 401 을 쓰고 연결을 닫는다(`Connection: close`)
|
||||
* - 서명은 원문을 받은 뒤 JSON 으로 읽기 전에 본다
|
||||
* 거절은 여기서 표준 실패 형식의 401 AUTH_001 을 쓴다(전역 필터와 같은 모양 — 요청마다 warn 을 남기지 않으려고).
|
||||
* 서명이 맞은 뒤의 실패(UTF-8 아님 · 모양 한도 — 엔진이 보낸 본문)는 전역 필터로 간다
|
||||
*/
|
||||
export function createMailEngineEventsBodyParser(
|
||||
settings: AccountSecuritySettings,
|
||||
rejections: MailEngineRejectionLog = new MailEngineRejectionLog((message) =>
|
||||
logger.warn(message),
|
||||
),
|
||||
): RequestHandler {
|
||||
const parse = json({
|
||||
limit: MAIL_ENGINE_EVENTS_JSON_LIMIT,
|
||||
inflate: false,
|
||||
type: (req: IncomingMessage) =>
|
||||
isMailEngineEventsRequest(req) &&
|
||||
(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() ===
|
||||
'application/json',
|
||||
verify: verifier(settings),
|
||||
});
|
||||
return (req, res, next) => {
|
||||
const reject = (
|
||||
rejection: MailEngineEventsRejection,
|
||||
bodyUnread: boolean,
|
||||
) => {
|
||||
const traceId = req.header(REQUEST_ID_HEADER) ?? undefined;
|
||||
rejections.note(rejection, traceId);
|
||||
// 본문을 받지 않고 끝낸 응답 — 연결을 닫아 남은 본문도 받지 않는다
|
||||
if (bodyUnread) res.setHeader('Connection', 'close');
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: getErrorDefinition('AUTH_001').message,
|
||||
...(traceId ? { traceId } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isMailEngineEventsRequest(req)) {
|
||||
if (!settings.isAppNetworkAddress(req.socket?.remoteAddress)) {
|
||||
reject(rejected('mail-engine-events-outside-app-network', req), true);
|
||||
return;
|
||||
}
|
||||
if (settings.webhookSecret === null) {
|
||||
reject(rejected('mail-engine-events-secret-missing', req), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
parse(req, res, (error?: unknown) => {
|
||||
if (error instanceof MailEngineEventsRejection) {
|
||||
reject(error, false);
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import type { LoginEventsService } from './login-events.service';
|
||||
import { MailEngineEventsService } from './mail-engine-events.service';
|
||||
|
||||
const settings: MailSettings = {
|
||||
jmapBaseUrl: 'http://mail:8080',
|
||||
domain: 'arasystem.test',
|
||||
gatewayUser: 'mail-gateway@arasystem.test',
|
||||
gatewayPassword: 'gateway-secret',
|
||||
attachmentMaxBytes: 20 * 1024 * 1024,
|
||||
attachmentTotalMaxBytes: 25 * 1024 * 1024,
|
||||
blockedAccounts: new Set([
|
||||
'admin@arasystem.test',
|
||||
'mail-directory@arasystem.test',
|
||||
]),
|
||||
directoryAccount: 'mail-directory@arasystem.test',
|
||||
};
|
||||
|
||||
const event = (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
createdAt = '2026-10-31T00:00:05Z',
|
||||
) => ({
|
||||
id: `${Math.random()}`,
|
||||
createdAt,
|
||||
type,
|
||||
data: {
|
||||
listenerId: 'imaps',
|
||||
remoteIp: '211.186.95.252',
|
||||
remotePort: 51234,
|
||||
spanId: 1,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
describe('MailEngineEventsService (엔진 웹훅 — 메일 프로그램의 로그인 기록)', () => {
|
||||
let inserted: Omit<LoginEventEntity, 'id'>[];
|
||||
let existingKeys: Set<string>;
|
||||
let loginEvents: {
|
||||
insertEngineEvents: jest.Mock;
|
||||
isKnownEmployee: jest.Mock;
|
||||
};
|
||||
let appPasswords: { recordUse: jest.Mock };
|
||||
let service: MailEngineEventsService;
|
||||
|
||||
beforeEach(() => {
|
||||
inserted = [];
|
||||
existingKeys = new Set();
|
||||
loginEvents = {
|
||||
insertEngineEvents: jest.fn((rows: Omit<LoginEventEntity, 'id'>[]) => {
|
||||
const fresh = rows.filter(
|
||||
(row) => !existingKeys.has(row.dedupeKey ?? ''),
|
||||
);
|
||||
inserted.push(...fresh);
|
||||
for (const row of fresh) existingKeys.add(row.dedupeKey ?? '');
|
||||
return Promise.resolve(
|
||||
new Set(fresh.map((row) => row.dedupeKey ?? '')),
|
||||
);
|
||||
}),
|
||||
isKnownEmployee: jest.fn((address: string) =>
|
||||
Promise.resolve(address !== 'nobody@arasystem.test'),
|
||||
),
|
||||
};
|
||||
appPasswords = { recordUse: jest.fn().mockResolvedValue('회사 PC 아웃룩') };
|
||||
service = new MailEngineEventsService(
|
||||
settings,
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 묶음은 아무것도 남기지 않고 끝난다 (setup 의 서명한 빈 묶음 — 200)', async () => {
|
||||
await expect(service.accept({ events: [] })).resolves.toEqual({
|
||||
kept: 0,
|
||||
duplicates: 0,
|
||||
dropped: 0,
|
||||
});
|
||||
expect(inserted).toEqual([]);
|
||||
});
|
||||
|
||||
it('IMAP · SMTP 의 성공 · 실패 · 2단계 필요를 남긴다 — 실패 까닭 · IP · 방식', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
listenerId: 'submissions',
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
spanId: 2,
|
||||
}),
|
||||
event('auth.mfa-required', {
|
||||
accountName: 'ara.kim@arasystem.test',
|
||||
spanId: 3,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 3, duplicates: 0, dropped: 0 });
|
||||
expect(
|
||||
inserted.map(({ userEmail, method, isSuccess, failureReason }) => ({
|
||||
userEmail,
|
||||
method,
|
||||
isSuccess,
|
||||
failureReason,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: true,
|
||||
failureReason: null,
|
||||
},
|
||||
{
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
method: 'smtp',
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
},
|
||||
{
|
||||
userEmail: 'ara.kim@arasystem.test',
|
||||
method: 'imap',
|
||||
isSuccess: false,
|
||||
failureReason: 'appPasswordRequired',
|
||||
},
|
||||
]);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
ip: '211.186.95.252',
|
||||
isTwoFactor: false,
|
||||
userAgent: null,
|
||||
appPasswordId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('사내 직원만 — 다른 도메인 · 막은 계정(관리자 · 사내 목록 계정 · 게이트웨이)은 버린다, 사내 목록에 없는 주소의 실패도 버린다', async () => {
|
||||
const result = await service.accept({
|
||||
events: [
|
||||
event('auth.failed', { accountName: 'someone@gmail.com' }),
|
||||
event('auth.success', { accountName: 'admin@arasystem.test' }),
|
||||
event('auth.success', { accountName: 'mail-gateway@arasystem.test' }),
|
||||
event('auth.failed', { accountName: 'nobody@arasystem.test' }),
|
||||
event('auth.failed', {
|
||||
accountName: 'nobody@arasystem.test',
|
||||
spanId: 9,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kept: 0, duplicates: 0, dropped: 5 });
|
||||
// 같은 묶음의 같은 주소는 사내 목록을 한 번만 묻는다
|
||||
expect(loginEvents.isKnownEmployee).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다', async () => {
|
||||
await service.accept({
|
||||
events: [
|
||||
event('auth.success', {
|
||||
accountName: 'doyun.lee',
|
||||
id: 1,
|
||||
details: 'Authenticated with app password',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(appPasswords.recordUse).toHaveBeenCalledWith(
|
||||
'doyun.lee@arasystem.test',
|
||||
'b',
|
||||
{
|
||||
at: new Date('2026-10-31T00:00:05Z'),
|
||||
method: 'imap',
|
||||
ip: '211.186.95.252',
|
||||
},
|
||||
);
|
||||
expect(inserted[0]).toMatchObject({
|
||||
userEmail: 'doyun.lee@arasystem.test',
|
||||
appPasswordId: 'b',
|
||||
appPasswordName: '회사 PC 아웃룩',
|
||||
});
|
||||
});
|
||||
|
||||
it('엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다 · 한 묶음 안의 같은 이벤트도', async () => {
|
||||
const original = event('auth.success', {
|
||||
accountName: 'doyun.lee@arasystem.test',
|
||||
});
|
||||
await service.accept({ events: [original, { ...original, id: 'again' }] });
|
||||
const retried = await service.accept({
|
||||
events: [{ ...original, id: 'retried-with-new-id' }],
|
||||
});
|
||||
|
||||
expect(inserted).toHaveLength(1);
|
||||
expect(retried).toEqual({ kept: 0, duplicates: 1, dropped: 0 });
|
||||
});
|
||||
|
||||
it('본문 모양이 다르면 아무것도 남기지 않고 warn 을 한 번만 남긴다 (본문은 남기지 않는다)', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await service.accept({ nope: true });
|
||||
await service.accept('text');
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).not.toContain('nope');
|
||||
expect(loginEvents.insertEngineEvents).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('DB 에 남기지 못하면 던진다 — 5xx 로 답해 엔진이 다시 보낸다', async () => {
|
||||
loginEvents.insertEngineEvents.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.accept({
|
||||
events: [
|
||||
event('auth.success', { accountName: 'doyun.lee@arasystem.test' }),
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isEmployeeAddress } from '@/modules/auth/employee-address';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { AppPasswordsService } from './app-passwords.service';
|
||||
import type { LoginEventEntity } from './login-event.entity';
|
||||
import { LoginEventsService } from './login-events.service';
|
||||
import {
|
||||
addressOfAccountName,
|
||||
failureReasonOf,
|
||||
readEngineEvents,
|
||||
} from './mail-engine-event';
|
||||
|
||||
/** 웹훅 한 번의 처리 결과 (debug 로그 · 테스트) */
|
||||
export interface EngineEventsAccepted {
|
||||
/** 새로 남긴 수 */
|
||||
readonly kept: number;
|
||||
/** 이미 남긴 것(엔진이 다시 보낸 묶음) */
|
||||
readonly duplicates: number;
|
||||
/** 버린 수 — 다른 종류 · 리스너 · 사내 직원이 아님 · 사내 목록에 없는 주소의 실패 · 막은 계정 */
|
||||
readonly dropped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 웹훅 받기 (계약 5절 "엔진 웹훅 받기") — 메일 프로그램(IMAP · SMTP)의 로그인을 로그인 기록에 남긴다.
|
||||
* 접속 주소 · 서명은 본문 파서가 이미 확인했다 (mail-engine-events.parser.ts).
|
||||
*
|
||||
* - 사내 도메인의 직원만 — 막은 계정(관리자 · 게이트웨이 · 사내 목록 계정)과 사내 목록에 없는 주소의 실패는 버린다(엔진은 서버 전체의 이벤트를 보낸다)
|
||||
* - 겹침은 이벤트 내용의 지문으로 거른다(엔진이 다시 보낸 묶음 — 엔진의 이벤트 id 는 보낼 때마다 바뀐다)
|
||||
* - 앱 비밀번호로 들어온 성공은 그 앱 비밀번호의 마지막 사용을 고치고, 기록에 그때의 이름을 남긴다
|
||||
* - 처리는 기다린 뒤 답한다(DB 한 번 — 수 ms). DB 가 실패하면 5xx 로 답해 엔진이 다시 보낸다(5분까지 — 지문이 같아 겹치지 않는다)
|
||||
* 웹훅 수신은 로그에 남기지 않는다(반복되는 정상 흐름) — 남긴 수 · 버린 수는 debug
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEngineEventsService {
|
||||
private readonly logger = new Logger(MailEngineEventsService.name);
|
||||
/** 본문 모양이 다르다는 warn 을 이미 남겼는가 (엔진 판이 바뀐 경우 — 한 번만) */
|
||||
private malformedReported = false;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings,
|
||||
private readonly loginEvents: LoginEventsService,
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
) {}
|
||||
|
||||
async accept(
|
||||
body: unknown,
|
||||
receivedAt: Date = new Date(),
|
||||
): Promise<EngineEventsAccepted> {
|
||||
const reading = readEngineEvents(body, receivedAt);
|
||||
if (reading.malformed) {
|
||||
if (!this.malformedReported) {
|
||||
this.malformedReported = true;
|
||||
this.logger.warn(
|
||||
'[MAIL_001] 엔진 웹훅 본문이 {"events":[…]} 모양이 아닙니다 — 메일 엔진 판이 바뀌었는지 확인하세요. (본문은 남기지 않는다)',
|
||||
);
|
||||
}
|
||||
return { kept: 0, duplicates: 0, dropped: 0 };
|
||||
}
|
||||
|
||||
const { domain, gatewayUser, blockedAccounts } = this.mailSettings;
|
||||
if (!domain) {
|
||||
return { kept: 0, duplicates: 0, dropped: reading.events.length };
|
||||
}
|
||||
|
||||
const rows = new Map<string, Omit<LoginEventEntity, 'id'>>();
|
||||
const known = new Map<string, boolean>();
|
||||
let dropped = reading.skipped;
|
||||
for (const event of reading.events) {
|
||||
const address = addressOfAccountName(event.accountName, domain);
|
||||
if (
|
||||
address === null ||
|
||||
!isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) ||
|
||||
rows.has(event.dedupeKey)
|
||||
) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
if (event.outcome !== 'success') {
|
||||
let isKnown = known.get(address);
|
||||
if (isKnown === undefined) {
|
||||
isKnown = await this.loginEvents.isKnownEmployee(address);
|
||||
known.set(address, isKnown);
|
||||
}
|
||||
if (!isKnown) {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const appPasswordName = event.appPasswordId
|
||||
? await this.appPasswords.recordUse(address, event.appPasswordId, {
|
||||
at: event.occurredAt,
|
||||
method: event.method,
|
||||
ip: event.remoteIp,
|
||||
})
|
||||
: null;
|
||||
rows.set(event.dedupeKey, {
|
||||
userEmail: address,
|
||||
occurredAt: event.occurredAt,
|
||||
method: event.method,
|
||||
isSuccess: event.outcome === 'success',
|
||||
isTwoFactor: false,
|
||||
failureReason: failureReasonOf(event.outcome),
|
||||
ip: event.remoteIp,
|
||||
userAgent: null,
|
||||
appPasswordId: event.appPasswordId,
|
||||
appPasswordName,
|
||||
dedupeKey: event.dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
const inserted = await this.loginEvents.insertEngineEvents([
|
||||
...rows.values(),
|
||||
]);
|
||||
const result = {
|
||||
kept: inserted.size,
|
||||
duplicates: rows.size - inserted.size,
|
||||
dropped,
|
||||
};
|
||||
if (result.kept + result.duplicates + result.dropped > 0) {
|
||||
this.logger.debug(
|
||||
`엔진 로그인 이벤트 — 남김 ${result.kept} · 이미 있음 ${result.duplicates} · 버림 ${result.dropped}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { TWO_FACTOR_CODE_ADDRESS_LIMIT } from '@/modules/auth/auth.constants';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const IP = '203.0.113.10';
|
||||
const accepted = () =>
|
||||
Promise.resolve({ kind: 'accepted' as const, value: 'ok' });
|
||||
const rejected = () => Promise.resolve({ kind: 'rejected' as const });
|
||||
|
||||
/** 손으로 푸는 약속 — 확인(run)이 걸려 있는 동안 다른 요청을 보낸다 */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('TwoFactorCodeGuard (코드 한도 · 다시 쓰기 막기 — 계약 5절)', () => {
|
||||
let now: number;
|
||||
let guard: TwoFactorCodeGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
now = 1_800_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
guard = new TwoFactorCodeGuard(new LoginAttemptService());
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('받으면 값을 돌려준다 · 받지 않으면 400 AUTH_006 (context 에 코드는 없다)', async () => {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await guard.check({ email: ARA, ip: IP }, '654321', rejected);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(BusinessException);
|
||||
expect((failure as BusinessException).code).toBe('AUTH_006');
|
||||
expect((failure as BusinessException).getStatus()).toBe(400);
|
||||
expect(
|
||||
JSON.stringify((failure as BusinessException).context),
|
||||
).not.toContain('654321');
|
||||
});
|
||||
|
||||
it(`성공에 쓴 코드는 ${TWO_FACTOR_CODE_REUSE_MS / 60_000}분 동안 다시 받지 않는다 — 확인(run) 없이 AUTH_006 · 실패로 센다, 다른 직원 · 다른 코드는 그대로`, async () => {
|
||||
guard.remember(ARA, '123456');
|
||||
const run = jest.fn(accepted);
|
||||
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', run)),
|
||||
).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
guard.check({ email: 'doyun.lee@arasystem.test', ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '111111', run),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
now += TWO_FACTOR_CODE_REUSE_MS;
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', run),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it(`실패가 ${TWO_FACTOR_CODE_ADDRESS_LIMIT}번 쌓이면 맞는 코드도 REQ_001 — 다시 쓴 코드도 실패로 센다`, async () => {
|
||||
guard.remember(ARA, '999999');
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '999999', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
for (let i = 1; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
'000000',
|
||||
rejected,
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('확인이 던지면(엔진 장애) 자리를 돌려주고 그대로 던진다 — 실패로 세지 않는다', async () => {
|
||||
const down = () => Promise.reject(new BusinessException('MAIL_001'));
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', down)),
|
||||
).toBe('MAIL_001');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
describe('같은 코드의 동시 요청 — 확인을 시작할 때 "확인 중"을 차지한다 (2026-10-31 리뷰)', () => {
|
||||
it('앞 요청이 확인하는 동안 같은 코드는 확인(run) 없이 400 AUTH_006 · 실패로 센다 — 앞이 받으면 그 코드는 쓴 코드다', async () => {
|
||||
const gate = deferred<{ kind: 'accepted'; value: string }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => gate.promise,
|
||||
);
|
||||
expect(guard.isPending(ARA, '123456')).toBe(true);
|
||||
|
||||
const run = jest.fn(accepted);
|
||||
const second = await codeOf(
|
||||
guard.check({ email: ARA, ip: '198.51.100.7' }, '123456', run),
|
||||
);
|
||||
expect(second).toBe('AUTH_006');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
// 다른 코드 · 다른 직원은 그대로 확인한다
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '654321', accepted),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
gate.resolve({ kind: 'accepted', value: 'first' });
|
||||
await expect(first).resolves.toBe('first');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
});
|
||||
|
||||
it('받으면 일이 끝나기 전에(돌려주는 그 자리에서) 기억한다 — 뒤의 일(세션 저장 등)이 걸려 있어도 같은 코드는 막힌다', async () => {
|
||||
await guard.check({ email: ARA, ip: IP }, '123456', accepted);
|
||||
// 부른 쪽이 아직 세션을 만드는 중이라고 해도 같은 코드는 이미 쓴 코드다
|
||||
expect(
|
||||
await codeOf(guard.check({ email: ARA, ip: IP }, '123456', accepted)),
|
||||
).toBe('AUTH_006');
|
||||
});
|
||||
|
||||
it('앞 요청이 받지 않았거나 던졌으면 차지를 풀어 같은 코드로 다시 확인할 수 있다', async () => {
|
||||
const rejectedGate = deferred<{ kind: 'rejected' }>();
|
||||
const first = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => rejectedGate.promise,
|
||||
);
|
||||
rejectedGate.resolve({ kind: 'rejected' });
|
||||
expect(await codeOf(first)).toBe('AUTH_006');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
|
||||
const down = deferred<never>();
|
||||
const second = guard.check(
|
||||
{ email: ARA, ip: IP },
|
||||
'123456',
|
||||
() => down.promise,
|
||||
);
|
||||
down.reject(new BusinessException('MAIL_001'));
|
||||
expect(await codeOf(second)).toBe('MAIL_001');
|
||||
expect(guard.isPending(ARA, '123456')).toBe(false);
|
||||
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('쓰지 않고 끝남(unused) — 값을 돌려주고 세지도 기억하지도 않는다 (예: 엔진이 새 비밀번호를 거절)', async () => {
|
||||
const unused = () =>
|
||||
Promise.resolve({ kind: 'unused' as const, value: 'nothing-changed' });
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', unused),
|
||||
).resolves.toBe('nothing-changed');
|
||||
}
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(false);
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('코드가 글이 아니어도(검사를 비켜 온 null) 차지한 실패 자리를 끝낸다 — 여러 번 와도 맞는 코드가 막히지 않는다', async () => {
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT + 2; i += 1) {
|
||||
expect(
|
||||
await codeOf(
|
||||
guard.check(
|
||||
{ email: ARA, ip: `198.51.100.${i}` },
|
||||
null as unknown as string,
|
||||
accepted,
|
||||
),
|
||||
),
|
||||
).toBe('NOT_BUSINESS');
|
||||
}
|
||||
await expect(
|
||||
guard.check({ email: ARA, ip: IP }, '123456', accepted),
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('정리 작업이 지난 기억을 지운다', () => {
|
||||
guard.remember(ARA, '123456');
|
||||
expect(guard.isUsed(ARA, '123456')).toBe(true);
|
||||
guard.sweep(now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
expect(guard.isUsed(ARA, '123456', now + TWO_FACTOR_CODE_REUSE_MS)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorLocks (같은 직원의 2단계 인증 바꾸기는 한 번에 하나)', () => {
|
||||
it('잡혀 있으면 기다리지 않고 REQ_001 — 끝나면 풀린다, 다른 직원은 따로', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
let release!: () => void;
|
||||
const first = locks.run(
|
||||
ARA,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await codeOf(locks.run(ARA, () => Promise.resolve()))).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
await expect(
|
||||
locks.run('doyun.lee@arasystem.test', () => Promise.resolve('other')),
|
||||
).resolves.toBe('other');
|
||||
|
||||
release();
|
||||
await first;
|
||||
await expect(locks.run(ARA, () => Promise.resolve('next'))).resolves.toBe(
|
||||
'next',
|
||||
);
|
||||
});
|
||||
|
||||
it('일이 던져도 풀린다 · 푸는 함수는 몇 번 불러도 한 번만', async () => {
|
||||
const locks = new TwoFactorLocks();
|
||||
expect(
|
||||
await codeOf(
|
||||
locks.run(ARA, () => Promise.reject(new BusinessException('MAIL_001'))),
|
||||
),
|
||||
).toBe('MAIL_001');
|
||||
const release = locks.acquire(ARA);
|
||||
release();
|
||||
release();
|
||||
expect(() => locks.acquire(ARA)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwoFactorEnrollments (기다리는 등록 — 직원마다 하나 · 10분)', () => {
|
||||
const secret = () => Buffer.alloc(20, 7);
|
||||
|
||||
it('새 등록이 앞의 것을 바꾼다 — 앞의 id 로는 찾지 못하고 그 비밀 바이트는 지운다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
const first = { id: 'first', secret: secret(), expiresAt: 2_000 };
|
||||
enrollments.put(ARA, first);
|
||||
enrollments.put(ARA, { id: 'second', secret: secret(), expiresAt: 2_000 });
|
||||
|
||||
expect(enrollments.find(ARA, 'first', 1_000)).toBeNull();
|
||||
expect(enrollments.find(ARA, 'second', 1_000)?.id).toBe('second');
|
||||
expect(first.secret.every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('시간이 지나면 찾지 못하고 버린다 · 정리 작업도 버린다', () => {
|
||||
const enrollments = new TwoFactorEnrollments();
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
expect(enrollments.find(ARA, 'e', 2_000)).toBeNull();
|
||||
expect(enrollments.size).toBe(0);
|
||||
|
||||
enrollments.put(ARA, { id: 'e', secret: secret(), expiresAt: 2_000 });
|
||||
enrollments.sweep(2_000);
|
||||
expect(enrollments.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { LoginAttemptService } from '@/modules/auth/login-attempt.service';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { TWO_FACTOR_CODE_REUSE_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 기억을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 코드 확인 한 번(`run`)의 결과
|
||||
* - `accepted`: 코드를 받았다 — 쓴 코드로 기억한다(2분). 뒤의 일(세션 · 기록 저장)이 실패해도 그 코드는 쓴 것으로 둔다
|
||||
* - `rejected`: 코드 틀림 — 실패로 센다(400 AUTH_006)
|
||||
* - `unused`: 코드와 상관없이 아무것도 바뀌지 않고 끝났다(엔진이 새 비밀번호를 거절하는 등) — 세지도 기억하지도 않는다
|
||||
*/
|
||||
export type CodeCheck<T> =
|
||||
| { readonly kind: 'accepted'; readonly value: T }
|
||||
| { readonly kind: 'rejected' }
|
||||
| { readonly kind: 'unused'; readonly value: T };
|
||||
|
||||
/** 코드를 확인하는 사람 — 정규화한 주소 · 클라이언트 IP */
|
||||
export interface CodeSource {
|
||||
readonly email: string;
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 인증 코드의 한도와 다시 쓰기 막기 (계약 5절 "한도 · 규칙") — 로그인 · 비밀번호 바꾸기 · 2단계 켜기 · 끄기가 함께 쓴다.
|
||||
*
|
||||
* - 코드 실패: 같은 주소 15분 5회 · 24시간 20회, 같은 IP 는 로그인 실패 한도에 함께 센다 (LoginAttemptService.beginCode) — 넘으면 REQ_001.
|
||||
* 확인하기 **전에** 실패 한 번을 차지하고, 맞으면 돌려준다 (로그인 실패 한도와 같은 결 — 동시 요청이 한도를 넘지 못하게)
|
||||
* - 다시 쓴 코드: 같은 직원이 성공에 쓴 코드는 2분 동안 다시 받지 않는다 → 400 AUTH_006 (코드 실패로 센다).
|
||||
* 엔진은 같은 코드를 다시 받으므로 서버가 막는다. 확인을 시작할 때 (주소, 코드)를 "확인 중"으로 차지하고(확인과 차지 사이에 await 가 없다)
|
||||
* 같은 코드가 겹치면 AUTH_006 이다 — 동시에 보낸 두 요청이 둘 다 지나가지 못하게 (2026-10-31 리뷰: 로그인 두 번이 모두 signedIn 이었다).
|
||||
* 받으면 그 자리에서 기억으로 바꾸고(뒤의 일이 실패해도), 받지 않았거나 던졌거나 쓰지 않고 끝났으면 차지를 푼다
|
||||
* - 코드 · 기억의 키에 원문을 두지 않는다 (주소 + 코드의 SHA-256). 코드는 로그 · 오류 context 어디에도 남기지 않는다
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorCodeGuard {
|
||||
/** 키(주소 + 코드의 해시) → 다시 받는 때(epoch ms) */
|
||||
private readonly usedCodes = new Map<string, number>();
|
||||
/** 지금 확인 중인 키 — 같은 코드의 동시 요청을 막는다 (확인이 끝나면 기억으로 바뀌거나 지워진다) */
|
||||
private readonly pendingCodes = new Set<string>();
|
||||
|
||||
constructor(private readonly attempts: LoginAttemptService) {}
|
||||
|
||||
/**
|
||||
* 코드 한 번을 확인한다 — 한도(REQ_001) → 다시 쓴 코드 · 확인 중인 같은 코드(AUTH_006) → `run`(엔진 · 등록의 비밀로 실제 확인).
|
||||
* 여기까지(실패 자리 차지 · 다시 쓴 코드 확인 · 확인 중 차지)는 await 없이 한 번에 일어난다.
|
||||
* - `accepted`: 차지한 자리를 돌려주고 코드를 기억한 뒤 값을 돌려준다
|
||||
* - `rejected`: 실패로 남기고 400 AUTH_006
|
||||
* - `unused`: 자리를 돌려주고(세지 않음) 기억하지 않고 값을 돌려준다
|
||||
* - `run` 이 던지면(엔진 장애 · 지금 비밀번호 틀림 등) 자리를 돌려주고 그대로 던진다 — 코드는 쓰지 않은 것으로 둔다
|
||||
*/
|
||||
async check<T>(
|
||||
source: CodeSource,
|
||||
code: string,
|
||||
run: () => Promise<CodeCheck<T>>,
|
||||
): Promise<T> {
|
||||
const ticket = this.attempts.beginCode(source);
|
||||
let key: string;
|
||||
try {
|
||||
key = codeKey(source.email, code);
|
||||
if (this.isUsedKey(key) || this.pendingCodes.has(key)) {
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-reused');
|
||||
}
|
||||
} catch (error) {
|
||||
// 차지한 실패 자리를 반드시 끝낸다 (이미 fail 했으면 두 번째 부름은 무시된다) — 코드가 글이 아닌 경우 등
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.add(key);
|
||||
|
||||
let result: CodeCheck<T>;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (error) {
|
||||
this.pendingCodes.delete(key);
|
||||
ticket.abort();
|
||||
throw error;
|
||||
}
|
||||
this.pendingCodes.delete(key);
|
||||
switch (result.kind) {
|
||||
case 'accepted':
|
||||
this.rememberKey(key);
|
||||
ticket.succeed();
|
||||
return result.value;
|
||||
case 'unused':
|
||||
ticket.abort();
|
||||
return result.value;
|
||||
case 'rejected':
|
||||
ticket.fail();
|
||||
throw this.rejected(source.email, 'two-factor-code-rejected');
|
||||
}
|
||||
}
|
||||
|
||||
/** 성공에 쓴 코드를 기억한다 (check 가 받은 코드를 스스로 기억한다 — 테스트 · 바깥에서 미리 막을 때) */
|
||||
remember(email: string, code: string, now: number = Date.now()): void {
|
||||
this.rememberKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 2분 안에 성공에 쓴 코드인가 */
|
||||
isUsed(email: string, code: string, now: number = Date.now()): boolean {
|
||||
return this.isUsedKey(codeKey(email, code), now);
|
||||
}
|
||||
|
||||
/** 지금 확인 중인 코드인가 (테스트) */
|
||||
isPending(email: string, code: string): boolean {
|
||||
return this.pendingCodes.has(codeKey(email, code));
|
||||
}
|
||||
|
||||
private rememberKey(key: string, now: number = Date.now()): void {
|
||||
// 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다)
|
||||
this.usedCodes.delete(key);
|
||||
this.usedCodes.set(key, now + TWO_FACTOR_CODE_REUSE_MS);
|
||||
}
|
||||
|
||||
private isUsedKey(key: string, now: number = Date.now()): boolean {
|
||||
const until = this.usedCodes.get(key);
|
||||
if (until === undefined) return false;
|
||||
if (until <= now) {
|
||||
this.usedCodes.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 지난 기억을 지운다 — 넣은 차례 = 끝나는 차례(기억 시간이 모두 같다) */
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [key, until] of this.usedCodes) {
|
||||
if (until > now) break;
|
||||
this.usedCodes.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private rejected(email: string, reason: string): BusinessException {
|
||||
return new BusinessException('AUTH_006', {
|
||||
context: { reason, email: maskEmail(email) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function codeKey(email: string, code: string): string {
|
||||
return createHash('sha256')
|
||||
.update(email)
|
||||
.update('\0')
|
||||
.update(code)
|
||||
.digest('base64url');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, PipeTransform } from '@nestjs/common';
|
||||
import { isUUID } from 'class-validator';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
|
||||
/**
|
||||
* 경로의 2단계 인증 등록 id (16단계 — `/api/two-factor/enrollments/{id}/verification`) — UUID 가 아니면 400 VAL_001.
|
||||
* 켜기 · 바꾸기 본문의 enrollmentId(IsUUID)와 같은 규칙이다 (모양은 맞는데 없는 등록은 서비스가 409 AUTH_008)
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollmentIdPipe implements PipeTransform<
|
||||
unknown,
|
||||
string
|
||||
> {
|
||||
transform(value: unknown): string {
|
||||
if (typeof value === 'string' && isUUID(value, 'all')) return value;
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'malformed-two-factor-enrollment-id' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
|
||||
import { TWO_FACTOR_ENROLLMENT_TTL_MS } from './account-security.constants';
|
||||
|
||||
/** 지난 등록을 지우는 주기 */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
|
||||
/** 기다리는 등록 하나 — 비밀은 켤 때까지 여기에만 있다 */
|
||||
export interface TwoFactorEnrollment {
|
||||
readonly id: string;
|
||||
readonly secret: Buffer;
|
||||
/** 끝나는 때 (epoch ms) */
|
||||
readonly expiresAt: number;
|
||||
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
|
||||
readonly verified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollments {
|
||||
private readonly pending = new Map<string, TwoFactorEnrollment>();
|
||||
|
||||
/** 등록을 둔다 — 앞의 등록은 버린다 */
|
||||
put(email: string, enrollment: TwoFactorEnrollment): void {
|
||||
this.forget(email);
|
||||
this.pending.set(email, enrollment);
|
||||
}
|
||||
|
||||
/** 그 id 의 기다리는 등록 — 없거나 · 시간이 지났거나 · 더 새 등록으로 바뀌었으면 null */
|
||||
find(
|
||||
email: string,
|
||||
id: string,
|
||||
now: number = Date.now(),
|
||||
): TwoFactorEnrollment | null {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return null;
|
||||
if (enrollment.expiresAt <= now) {
|
||||
this.forget(email);
|
||||
return null;
|
||||
}
|
||||
return enrollment.id === id ? enrollment : null;
|
||||
}
|
||||
|
||||
/** 그 id 의 등록에 "확인됨"을 단다 (새 휴대폰의 코드가 맞았다) — 등록이 없거나 지났거나 바뀌었으면 false */
|
||||
markVerified(email: string, id: string, now: number = Date.now()): boolean {
|
||||
const enrollment = this.find(email, id, now);
|
||||
if (!enrollment) return false;
|
||||
this.pending.set(email, { ...enrollment, verified: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 등록을 버린다 — 비밀 바이트도 지운다 */
|
||||
forget(email: string): void {
|
||||
const enrollment = this.pending.get(email);
|
||||
if (!enrollment) return;
|
||||
enrollment.secret.fill(0);
|
||||
this.pending.delete(email);
|
||||
}
|
||||
|
||||
@Interval(SWEEP_INTERVAL_MS)
|
||||
sweep(now: number = Date.now()): void {
|
||||
for (const [email, enrollment] of this.pending) {
|
||||
if (enrollment.expiresAt <= now) this.forget(email);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
}
|
||||
|
||||
/** 등록이 끝나는 때 — 지금부터 10분 */
|
||||
export function enrollmentExpiry(now: number): number {
|
||||
return now + TWO_FACTOR_ENROLLMENT_TTL_MS;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
|
||||
/**
|
||||
* 같은 직원의 2단계 인증 바꾸기는 한 번에 하나 (계약 5절 "2단계 인증") — 등록 · 켜기 · 끄기 · 2단계를 켠 직원의 비밀번호 바꾸기.
|
||||
* 처리 중이면 기다리지 않고 429 REQ_001 (로그인의 진행 중 잠금과 같은 결 — 버튼을 두 번 누른 경우 등).
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 잠금(SET NX PX)으로 옮긴다 (nestjs 스킬 "확장 때 옮길 것").
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorLocks {
|
||||
private readonly busy = new Set<string>();
|
||||
|
||||
/** 잠금을 잡고 일을 하고 푼다 — 잡혀 있으면 REQ_001 (잡는 확인과 넣기 사이에 await 가 없다) */
|
||||
async run<T>(email: string, work: () => Promise<T>): Promise<T> {
|
||||
const release = this.acquire(email);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/** 잠금을 잡는다 → 푸는 함수 (몇 번 불러도 한 번만 푼다). 잡혀 있으면 REQ_001 */
|
||||
acquire(email: string): () => void {
|
||||
if (this.busy.has(email)) {
|
||||
throw new BusinessException('REQ_001', {
|
||||
context: {
|
||||
reason: 'two-factor-change-in-progress',
|
||||
email: maskEmail(email),
|
||||
},
|
||||
});
|
||||
}
|
||||
this.busy.add(email);
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.busy.delete(email);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
|
||||
/**
|
||||
* 웹메일에서 2단계 인증을 켠 때 (계약 5절 "2단계 인증") — 직원 주소마다 한 줄.
|
||||
* **엔진이 기준이다** — 켜짐 여부는 엔진(x:AccountPassword 의 otpUrl)으로 보고, 여기는 켠 때만 둔다.
|
||||
* 엔진에서 꺼졌으면 지운다. 엔진 관리 화면에서 켠 2단계 인증은 줄이 없다(enabledAt null). 비밀은 두지 않는다
|
||||
*/
|
||||
@Entity({ name: 'two_factor_settings' })
|
||||
export class TwoFactorSettingEntity {
|
||||
@PrimaryColumn({
|
||||
name: 'user_email',
|
||||
type: 'varchar',
|
||||
length: EMAIL_COLUMN_LENGTH,
|
||||
})
|
||||
userEmail!: string;
|
||||
|
||||
@Column({ name: 'enabled_at', type: 'timestamptz' })
|
||||
enabledAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
TwoFactorActivationDto,
|
||||
TwoFactorDeactivationDto,
|
||||
TwoFactorEnrollmentDto,
|
||||
TwoFactorReplacementDto,
|
||||
TwoFactorStatusDto,
|
||||
TwoFactorVerificationDto,
|
||||
TwoFactorVerifiedDto,
|
||||
} from './dto/two-factor.dto';
|
||||
import { TwoFactorEnrollmentIdPipe } from './two-factor-enrollment-id.pipe';
|
||||
import {
|
||||
TwoFactorService,
|
||||
type TwoFactorStatusView,
|
||||
} from './two-factor.service';
|
||||
|
||||
function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
|
||||
return {
|
||||
isEnabled: status.isEnabled,
|
||||
enabledAt: status.enabledAt?.toISOString() ?? null,
|
||||
isRequired: status.isRequired,
|
||||
deadline: status.deadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// 2단계 인증 (계약 5절 "2단계 인증") — 판단은 모두 TwoFactorService 가 한다. 요청 수 상한은 전역 기본값 —
|
||||
// 코드 · 비밀번호 맞혀 보기는 로그인과 같은 실패 한도(코드 15분 5회 · 24시간 20회, 비밀번호 15분 10회)가 막는다
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010
|
||||
@Controller('two-factor')
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactor: TwoFactorService) {}
|
||||
|
||||
@Get()
|
||||
@AllowRestrictedSession()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 상태',
|
||||
description:
|
||||
'엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다. ' +
|
||||
'13단계: isRequired · deadline — 회사의 2단계 인증 필수 정책. 제한된 세션도 부른다.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' })
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async status(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
return toStatusDto(
|
||||
await this.twoFactor.withPolicy(
|
||||
user.email,
|
||||
await this.twoFactor.getStatus(user.email),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('enrollments')
|
||||
@AllowRestrictedSession()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)',
|
||||
description:
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' +
|
||||
'응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorEnrollmentDto, {
|
||||
status: 201,
|
||||
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
|
||||
})
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음')
|
||||
async enroll(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorEnrollmentDto> {
|
||||
const enrollment = await this.twoFactor.startEnrollment(user.email);
|
||||
return {
|
||||
enrollmentId: enrollment.enrollmentId,
|
||||
secret: enrollment.secret,
|
||||
otpauthUrl: enrollment.otpauthUrl,
|
||||
expiresAt: enrollment.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('enrollments/:id/verification')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '휴대폰 바꾸기 ① — 새 휴대폰의 코드 확인 (16단계)',
|
||||
description:
|
||||
'새 휴대폰의 코드를 등록의 비밀로 확인하고(앞뒤 30초) 등록에 "확인됨"을 단다 — 엔진은 등록 때 코드를 보지 않는다. 엔진에는 아직 아무것도 바꾸지 않는다. ' +
|
||||
'받은 코드는 2분 동안 다시 받지 않는다(코드 실패 한도 · 같은 코드 막기는 켜기 · 끄기와 같다). 확인은 등록에 남는다(10분) — [이전]으로 돌아와도 다시 확인하지 않아도 된다. ' +
|
||||
'제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorVerifiedDto, { description: '확인했다' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 코드 · 등록 id 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 — 화면은 처음부터 다시(새 등록)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 처리 중',
|
||||
)
|
||||
async verifyEnrollment(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Param('id', TwoFactorEnrollmentIdPipe) id: string,
|
||||
@Body() dto: TwoFactorVerificationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorVerifiedDto> {
|
||||
await this.twoFactor.verifyEnrollment(
|
||||
user.email,
|
||||
{ enrollmentId: id, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
);
|
||||
return { verified: true };
|
||||
}
|
||||
|
||||
@Post('replacements')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'휴대폰 바꾸기 ② — 지금 휴대폰의 코드와 지금 비밀번호로 바꾼다 (16단계)',
|
||||
description:
|
||||
'①에서 확인한 등록만 받는다. 지금 비밀번호를 먼저 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다, 그사이 2단계가 꺼졌으면 AUTH_008) ' +
|
||||
'지금 휴대폰의 코드와 새 비밀을 엔진 요청 하나로 보낸다 — 2단계가 꺼지는 틈이 없고 옛 휴대폰의 코드는 바로 막힌다. ' +
|
||||
'지금 코드가 틀리거나 2분 안에 다시 쓴 코드면 AUTH_006(코드 실패로 센다 — 등록은 남아 다시 보낼 수 있다). ' +
|
||||
'결과를 모르면 503 MAIL_001 — 엔진이 비밀을 가려 보여 주어 다시 읽어도 가를 수 없다(등록은 남는다). 켠 때(enabledAt)는 그대로, 로그인된 기기는 끊지 않는다. ' +
|
||||
'제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, { description: '바꿨다' })
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 지금 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록 · 확인(①)하지 않은 등록 · 그사이 다른 곳에서 꺼짐 — 화면은 처음부터 다시',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(가를 수 없다 — 다시 보내기 전에 새 휴대폰의 코드로 로그인되는지 본다)',
|
||||
)
|
||||
async replace(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorReplacementDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
const status = await this.twoFactor.replace(
|
||||
user.email,
|
||||
{
|
||||
enrollmentId: dto.enrollmentId,
|
||||
currentOtpCode: dto.currentOtpCode,
|
||||
currentPassword: dto.currentPassword,
|
||||
},
|
||||
{ ip },
|
||||
);
|
||||
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
|
||||
}
|
||||
|
||||
@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 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_008',
|
||||
'등록이 없음 · 10분 지남 · 더 새 등록으로 바뀜 · 그사이 다른 곳에서 켜짐 — 화면은 처음부터 다시',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'코드 실패 한도(같은 주소 15분 5회 · 24시간 20회) · 비밀번호 실패 한도 · 처리 중',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 켜졌으면 성공으로 답한다)',
|
||||
)
|
||||
async activate(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorActivationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
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')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드',
|
||||
description:
|
||||
'비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' +
|
||||
'코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다. ' +
|
||||
'13단계: 회사 정책으로 필수인 직원은 400 VAL_001(비밀번호를 묻기 전에). 제한된 세션은 403 AUTH_010.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorStatusDto, {
|
||||
description: '껐다 (또는 이미 꺼져 있었다)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 · 회사 정책으로 필수인 직원 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_010',
|
||||
'제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)',
|
||||
)
|
||||
@ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중')
|
||||
@ApiErrorResponse(
|
||||
'MAIL_001',
|
||||
'메일 엔진 장애 · 결과를 알 수 없음(엔진을 다시 읽어 꺼졌으면 성공으로 답한다)',
|
||||
)
|
||||
async deactivate(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: TwoFactorDeactivationDto,
|
||||
@Ip() ip: string,
|
||||
): Promise<TwoFactorStatusDto> {
|
||||
const status = await this.twoFactor.deactivate(
|
||||
user.email,
|
||||
{ currentPassword: dto.currentPassword, otpCode: dto.otpCode },
|
||||
{ ip },
|
||||
);
|
||||
return toStatusDto(await this.twoFactor.withPolicy(user.email, status));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user