generated from ttipo/project-base
Compare commits
131 Commits
bc22f5f3ba
...
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 |
@@ -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` 복사 여부를 먼저 확인한다.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 작업 점검 & 신규 프로젝트 셋업
|
||||
# 작업 점검 & 새 환경 셋업
|
||||
|
||||
> 이 저장소는 **재사용 템플릿**이다. 클론해 어떤 프로젝트든 시작할 수 있도록, 여기에는 **언제나 유효한 공용 규칙**만 둔다(특정 시점의 구현 상태는 두지 않는다).
|
||||
> 각 스택이 기본 제공하는 골격과 확장 규칙은 [[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` 이 남으면 기동 실패한다
|
||||
@@ -85,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 가 주입한 값을 그대로 사용**한다.
|
||||
|
||||
```
|
||||
@@ -51,7 +51,10 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
규칙:
|
||||
|
||||
- **새 설정값은 루트 `.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` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
|
||||
|
||||
@@ -29,30 +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 이면 세션을 갱신하고 같은 요청을 다시 보낸다 | "지금 비밀번호가 올바르지 않습니다." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요." |
|
||||
| AUTH_006 | 400 | 2단계 인증 코드 틀림(로그인 코드 단계 · 켜기 · 끄기 · 비밀번호 바꾸기 — 12단계). 401 이 아니다(위와 같은 까닭) | "코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요." |
|
||||
| AUTH_007 | 400 | 2단계 인증을 켠 직원인데 코드가 없다 — 화면은 코드 칸을 보인다 | "인증 앱의 코드를 입력해 주세요." |
|
||||
| AUTH_008 | 409 | 2단계 등록이 그사이 바뀌었거나 10분이 지났다 · 이미 켜져 있다 — 처음부터(새 키) | "2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요." |
|
||||
| AUTH_009 | 409 | 앱 비밀번호 상한(5개) | "5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요." |
|
||||
| AUTH_010 | 403 | 제한된 세션(새 비밀번호 정하기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API. 화면은 알림 없이 `me` 를 다시 받아 그 단계로 간다. 403 의 상태 역매핑은 그대로 `AUTH_003` | "먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요." |
|
||||
| VAL_001 | 400 / 422 | 입력 검증 실패 | "입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)" |
|
||||
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
|
||||
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
|
||||
| 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 를 쿼리째 남긴다 — 오류 로그를 볼 수 있는 사람을 운영 담당으로 한정한다
|
||||
|
||||
|
||||
+25
-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회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
|
||||
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
|
||||
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
|
||||
@@ -64,7 +64,7 @@
|
||||
`img onerror` 같은 처리기가 실행된다. CSP 가 막아도 그것에만 기대지 않는다 ([[vue3]] 체크리스트 "외부 HTML")
|
||||
### 파일 업로드
|
||||
|
||||
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
`modules/upload` 에 로컬 디스크 기준 골격이 있다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
|
||||
|
||||
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
|
||||
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
|
||||
@@ -102,6 +102,16 @@
|
||||
같은 까닭으로 요청 대상의 날 `\`(경로) · `#` · 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)도 따라가지 않는다.
|
||||
|
||||
## 네트워크 · 인프라
|
||||
|
||||
- 보안 헤더(`helmet`) 적용
|
||||
@@ -159,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 로 끊는다
|
||||
|
||||
@@ -194,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`.
|
||||
> 새 로직을 추가하면 최소한의 테스트를 함께 작성한다.
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
@@ -85,7 +85,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
이 경로만 오류 로그를 `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 확인).
|
||||
@@ -106,13 +106,19 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`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/`) —
|
||||
⚠️ **받기 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 설정").
|
||||
@@ -180,13 +186,16 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
`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절 "실시간 알림"):
|
||||
@@ -213,7 +222,8 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
긴 한글 이름 파일의 받기 응답 머리글(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` 바인드 마운트다.
|
||||
백업은 매일 백업(`ops/` — 아래 "백업 · 감시")에 든다.
|
||||
@@ -330,7 +340,8 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
`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` 을 타는지 실제 요청으로 본다.
|
||||
"대용량 받기 경로 시험"은 같은 이미지에 가짜 백엔드를 얹어 받기 경로의 대소문자 · `%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)` 를 지시어로만 센다(주석 처리한 줄은 세지 않는다).
|
||||
@@ -484,7 +495,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- **백업**(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`.
|
||||
- **복원**(`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 -`.
|
||||
@@ -498,7 +509,7 @@ 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 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
|
||||
| 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 의 이미지를 읽어 쓴다) |
|
||||
@@ -508,6 +519,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| 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).
|
||||
@@ -568,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)에는
|
||||
@@ -588,7 +603,7 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
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` 동안 처리 중인 요청을 마치도록 한다.
|
||||
|
||||
+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
@@ -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
|
||||
|
||||
@@ -236,6 +236,11 @@ jobs:
|
||||
[ "$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
|
||||
@@ -253,7 +258,9 @@ jobs:
|
||||
# 대용량 받기 경로(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 을 가른다
|
||||
# ?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 }}"
|
||||
@@ -290,26 +297,42 @@ jobs:
|
||||
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"
|
||||
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더"
|
||||
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; do expect "429 -" portal.ci.test "$p"; done
|
||||
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"
|
||||
|
||||
@@ -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(역할) · 설정
|
||||
```
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -43,6 +43,8 @@ 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)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
|
||||
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
|
||||
|
||||
+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`를 돌립니다. 그래도 배포되면 서버는 그 글만 건너뛰고 로그에 파일 이름과 줄 번호를 남깁니다. 서버는 그대로 뜹니다.
|
||||
- 앱은 옛 판이어도 새 글을 받으니, 앱 이야기를 쓸 때 새 판을 내지 않아도 됩니다.
|
||||
@@ -9,12 +9,16 @@ 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';
|
||||
@@ -80,6 +84,14 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
|
||||
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,
|
||||
],
|
||||
|
||||
@@ -72,7 +72,7 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) {
|
||||
|
||||
/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */
|
||||
const SESSION_REQUIRED_DESCRIPTION =
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) — 클라이언트는 /auth/refresh 를 한 번 시도한다';
|
||||
'로그인 필요 (쿠키 · 앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 웹 · 앱 토큰을 바꿔 씀) — 클라이언트는 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다';
|
||||
|
||||
/**
|
||||
* 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전).
|
||||
|
||||
@@ -4,6 +4,12 @@ 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 {
|
||||
/** 직원 메일 주소 (= 메일 계정) */
|
||||
@@ -20,6 +26,11 @@ export interface AuthenticatedUser {
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
/**
|
||||
* 앱 토큰(`Authorization: Bearer` — 표시 `cli: 'app'`)으로 들어온 요청이면 'app' — 가드를 거친 요청에만 있다. 웹(쿠키)이면 없다.
|
||||
* 앱 요청은 리프레시 쿠키가 없으므로 "지금 세션"을 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
|
||||
*/
|
||||
readonly client?: SessionClient;
|
||||
/**
|
||||
* 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다.
|
||||
* JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다
|
||||
|
||||
@@ -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절)
|
||||
@@ -56,7 +58,7 @@ const COMMON_ERROR_CODES = {
|
||||
AUTH_006: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'코드가 올바르지 않습니다. 휴대폰의 날짜와 시간이 「자동」으로 맞춰져 있는지 확인해 주세요.',
|
||||
'코드가 맞지 않습니다. 휴대폰 설정에서 날짜와 시간을 자동으로 맞추게 해 두었는지 확인해 주세요.',
|
||||
},
|
||||
// 2단계 인증을 켠 직원의 요청에 코드가 없음 — 다른 곳에서 켠 뒤의 화면. 화면은 코드 칸을 보인다 (계약 5절)
|
||||
AUTH_007: {
|
||||
@@ -83,15 +85,16 @@ const COMMON_ERROR_CODES = {
|
||||
},
|
||||
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>;
|
||||
|
||||
@@ -108,22 +111,24 @@ 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: '파일 올리기 상태가 맞지 않습니다. 다시 시도해 주세요.',
|
||||
message:
|
||||
'그사이 파일이 바뀌어 하던 일을 마치지 못했습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 조각의 Content-Digest(sha-256)가 받은 바이트와 다르다 — 그 조각을 받지 않았다 (같은 조각을 다시 보낸다)
|
||||
FILE_004: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message: '파일 조각이 보내는 중에 손상되었습니다. 다시 시도해 주세요.',
|
||||
message: '파일 일부가 제대로 올라가지 않았습니다. 다시 시도해 주세요.',
|
||||
},
|
||||
// 직원 보관 한도(보관량 · 보내지 않은 파일 수)를 넘는다 — 만들지 않았다. 문구에 숫자를 넣지 않는다(한도가 env 로 바뀐다)
|
||||
FILE_005: {
|
||||
@@ -135,7 +140,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
FILE_006: {
|
||||
status: HttpStatus.INSUFFICIENT_STORAGE,
|
||||
message:
|
||||
'서버의 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
'회사 저장 공간이 모자라 지금은 파일을 올릴 수 없습니다. 잠시 뒤 다시 시도해 주세요.',
|
||||
},
|
||||
// 더 내려받을 수 없는 파일 — 기간 지남 · 공유 멈춤 · 횟수 초과 · 위험 · 찾을 수 없음 (공개 받기 · 우리 웹메일 받기)
|
||||
FILE_007: {
|
||||
@@ -151,7 +156,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
// 메일 — 외부 메일 엔진(Stalwart) 장애를 SYS_001(우리 버그)과 구분하기 위해 분리한다
|
||||
MAIL_001: {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
message: '메일 서버와 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
message: '메일 서비스에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
},
|
||||
MAIL_002: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
@@ -160,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 로 이미 보낸 메일과 내용(받는 사람·제목·본문·첨부)이 다르다 — 보내지 않는다.
|
||||
// 첫 메일이 실제로 나갔을 수 있으므로 클라이언트는 보낸편지함을 확인하게 하고 새 키로 다시 보내게 한다
|
||||
@@ -180,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절)
|
||||
@@ -224,7 +229,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_013: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나 쓰지 않는 필터 · 차단 주소를 지워 주세요.',
|
||||
'필터나 차단 목록이 너무 길어 저장할 수 없습니다. 조건을 줄이거나, 쓰지 않는 필터나 차단한 주소를 지워 주세요.',
|
||||
},
|
||||
// 붙잡아 둔 발송(보내기 취소 · 예약 — 9단계)을 취소하려 했는데 이미 보냈다(엔진이 풀었음) · 기다리는 발송이 아니다 — 아무것도 바꾸지 않았다.
|
||||
// 화면은 인터셉터 알림으로 보인다 (계약 2절 "보내기 취소 · 예약 발송")
|
||||
@@ -245,7 +250,7 @@ const DOMAIN_ERROR_CODES = {
|
||||
MAIL_016: {
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
message:
|
||||
'대용량 첨부 파일을 보낼 수 없습니다. 해당 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
'보낼 수 없는 대용량 첨부 파일이 있습니다. 그 파일을 빼거나 다시 올린 뒤 보내 주세요.',
|
||||
},
|
||||
// 주소록(8단계) — 그룹 만들기 · 이름 바꾸기 때 이 주소록에 같은 이름의 그룹이 있다(앞뒤 공백을 빼고 ASCII 대소문자를 무시하고 견준다).
|
||||
// 화면은 이름 칸 아래에 알림 없이 보인다 (계약 3절)
|
||||
@@ -258,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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,16 @@ 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()
|
||||
*/
|
||||
|
||||
+19
-2
@@ -13,6 +13,7 @@ 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,6 +22,10 @@ import {
|
||||
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
/** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */
|
||||
const SWAGGER_COOKIE_AUTH = 'cookie';
|
||||
const SWAGGER_BEARER_AUTH = 'bearer';
|
||||
|
||||
async function bootstrap() {
|
||||
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
@@ -72,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,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"`);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,14 @@ export type TwoFactorEnableResult = 'enabled' | 'refused' | '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>;
|
||||
@@ -64,6 +72,17 @@ export interface AccountSecurityProvider {
|
||||
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[]>;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export class LoginEventQueryDto extends CursorPaginationQueryDto {
|
||||
export class DeviceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet — 모르면 null',
|
||||
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet · ARA SYSTEM 앱(모바일 앱 — 계약 1절 "앱 로그인") — 모르면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'Chrome',
|
||||
@@ -76,18 +76,18 @@ export class LoginEventDto {
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'webmail · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
|
||||
enum: ['webmail', 'imap', 'smtp'],
|
||||
'webmail · app(모바일 앱 — 계약 1절 "앱 로그인") · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
|
||||
enum: ['webmail', 'app', 'imap', 'smtp'],
|
||||
example: 'webmail',
|
||||
})
|
||||
method!: 'webmail' | 'imap' | 'smtp';
|
||||
method!: 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
@ApiProperty({ description: '성공했는가', example: true })
|
||||
isSuccess!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
|
||||
'웹메일 · 앱 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
|
||||
example: true,
|
||||
})
|
||||
isTwoFactor!: boolean;
|
||||
@@ -113,7 +113,7 @@ export class LoginEventDto {
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'웹메일의 브라우저 · 운영체제 — 메일 프로그램이거나 모르면 null',
|
||||
'웹메일 · 앱의 브라우저(앱은 ARA SYSTEM 앱) · 운영체제 — 메일 프로그램이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
|
||||
@@ -101,6 +101,53 @@ export class TwoFactorActivationDto {
|
||||
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({
|
||||
|
||||
@@ -15,8 +15,11 @@ const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
|
||||
/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */
|
||||
const DEDUPE_KEY_LENGTH = 43;
|
||||
|
||||
/** 로그인 방식 — 웹메일 · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
|
||||
export type LoginEventMethod = 'webmail' | 'imap' | 'smtp';
|
||||
/** 로그인 방식 — 웹메일 · 앱(모바일 — 계약 1절 "앱 로그인") · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
|
||||
export type LoginEventMethod = 'webmail' | 'app' | 'imap' | 'smtp';
|
||||
|
||||
/** 서버가 판정한 로그인의 방식 — 웹메일 · 앱 (메일 프로그램은 엔진 웹훅이 알려 준다) */
|
||||
export type SignInMethod = Extract<LoginEventMethod, 'webmail' | 'app'>;
|
||||
|
||||
/**
|
||||
* 실패 까닭 (계약 5절 "로그인 기록"):
|
||||
@@ -31,7 +34,7 @@ export type LoginFailureReason =
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts).
|
||||
* - 웹메일: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
* - 웹메일 · 앱: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
* - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다
|
||||
* 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다)
|
||||
*/
|
||||
@@ -56,7 +59,7 @@ export class LoginEventEntity {
|
||||
@Column({ name: 'is_success', type: 'boolean' })
|
||||
isSuccess!: boolean;
|
||||
|
||||
/** 웹메일 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
|
||||
/** 웹메일 · 앱 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
|
||||
@Column({ name: 'is_two_factor', type: 'boolean' })
|
||||
isTwoFactor!: boolean;
|
||||
|
||||
@@ -71,7 +74,7 @@ export class LoginEventEntity {
|
||||
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
|
||||
ip!: string | null;
|
||||
|
||||
/** 웹메일의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
|
||||
/** 웹메일 · 앱의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
|
||||
@Column({
|
||||
name: 'user_agent',
|
||||
type: 'varchar',
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
type AuthenticatedUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { describeUserAgent } from '@/shared/utils/user-agent';
|
||||
import {
|
||||
describeAppUserAgent,
|
||||
describeUserAgent,
|
||||
} from '@/shared/utils/user-agent';
|
||||
import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants';
|
||||
import {
|
||||
LoginEventPageDto,
|
||||
@@ -30,15 +33,21 @@ function toDto(event: LoginEventEntity): LoginEventDto {
|
||||
isTwoFactor: event.isTwoFactor,
|
||||
failureReason: event.failureReason,
|
||||
ip: event.ip,
|
||||
// 웹메일 · 앱만 기기를 안다(User-Agent) — 메일 프로그램은 엔진이 알려 주지 않는다.
|
||||
// 앱 이름(ARA SYSTEM 앱)은 서버가 앱 경로로 판정한 기록에만 — 웹메일 기록의 User-Agent 가 앱 꼴이어도 붙이지 않는다
|
||||
device:
|
||||
event.method === 'webmail' ? describeUserAgent(event.userAgent) : null,
|
||||
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)의 로그인
|
||||
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱과 메일 프로그램(IMAP · SMTP)의 로그인
|
||||
@ApiTags('Account security')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -51,7 +60,7 @@ export class LoginEventsController {
|
||||
@ApiOperation({
|
||||
summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`,
|
||||
description:
|
||||
'웹메일: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
|
||||
'웹메일 · 앱: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
|
||||
'메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' +
|
||||
`기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`,
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LoginEventsService, retentionStart } from './login-events.service';
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const NOW = new Date('2026-10-31T03:00:00.000Z');
|
||||
|
||||
describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계약 5절)', () => {
|
||||
describe('LoginEventsService.recordSignIn (웹메일 · 앱 로그인 기록 — 계약 5절)', () => {
|
||||
let events: { insert: jest.Mock };
|
||||
let directory: { hasEmail: jest.Mock };
|
||||
let service: LoginEventsService;
|
||||
@@ -34,8 +34,9 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const attempt = (
|
||||
overrides: Partial<Parameters<LoginEventsService['recordWebmail']>[0]>,
|
||||
overrides: Partial<Parameters<LoginEventsService['recordSignIn']>[0]>,
|
||||
) => ({
|
||||
method: 'webmail' as const,
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
@@ -46,7 +47,7 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
|
||||
});
|
||||
|
||||
it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => {
|
||||
await service.recordWebmail(
|
||||
await service.recordSignIn(
|
||||
attempt({
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
@@ -55,7 +56,7 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
|
||||
}),
|
||||
NOW,
|
||||
);
|
||||
await service.recordWebmail(attempt({ failureReason: 'wrongCode' }), NOW);
|
||||
await service.recordSignIn(attempt({ failureReason: 'wrongCode' }), NOW);
|
||||
|
||||
expect(directory.hasEmail).not.toHaveBeenCalled();
|
||||
expect(events.insert).toHaveBeenCalledTimes(2);
|
||||
@@ -75,21 +76,21 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
|
||||
|
||||
it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => {
|
||||
directory.hasEmail.mockResolvedValueOnce(false);
|
||||
await service.recordWebmail(
|
||||
await service.recordSignIn(
|
||||
attempt({ email: 'nobody@arasystem.test' }),
|
||||
NOW,
|
||||
);
|
||||
expect(events.insert).not.toHaveBeenCalled();
|
||||
|
||||
directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001'));
|
||||
await service.recordWebmail(attempt({}), NOW);
|
||||
await service.recordSignIn(attempt({}), NOW);
|
||||
expect(events.insert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => {
|
||||
events.insert.mockRejectedValue(new Error('db down'));
|
||||
await expect(
|
||||
service.recordWebmail(
|
||||
service.recordSignIn(
|
||||
attempt({ isSuccess: true, failureReason: null }),
|
||||
NOW,
|
||||
),
|
||||
@@ -98,6 +99,25 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
LoginEventEntity,
|
||||
type LoginFailureReason,
|
||||
type SignInMethod,
|
||||
} from './login-event.entity';
|
||||
import {
|
||||
decodeLoginEventCursor,
|
||||
@@ -28,8 +29,10 @@ import {
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 웹메일 로그인 시도 하나 — 서버가 판정한 것 */
|
||||
export interface WebmailLoginAttempt {
|
||||
/** 웹메일 · 앱 로그인 시도 하나 — 서버가 판정한 것 */
|
||||
export interface SignInAttempt {
|
||||
/** webmail(웹 — 쿠키) · app(모바일 앱 — 계약 1절 "앱 로그인") */
|
||||
readonly method: SignInMethod;
|
||||
readonly email: string;
|
||||
readonly isSuccess: boolean;
|
||||
/** 성공이 코드까지 거쳤는가 */
|
||||
@@ -56,7 +59,7 @@ export function retentionStart(now: Date): Date {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
|
||||
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
|
||||
*
|
||||
* - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도
|
||||
* 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다
|
||||
@@ -74,9 +77,9 @@ export class LoginEventsService {
|
||||
private readonly directory: DirectoryService,
|
||||
) {}
|
||||
|
||||
/** 웹메일 로그인 시도를 남긴다 — 던지지 않는다 */
|
||||
async recordWebmail(
|
||||
attempt: WebmailLoginAttempt,
|
||||
/** 웹메일 · 앱 로그인 시도를 남긴다 — 던지지 않는다 */
|
||||
async recordSignIn(
|
||||
attempt: SignInAttempt,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(attempt.email);
|
||||
@@ -89,7 +92,7 @@ export class LoginEventsService {
|
||||
await this.events.insert({
|
||||
userEmail: address,
|
||||
occurredAt: now,
|
||||
method: 'webmail',
|
||||
method: attempt.method,
|
||||
isSuccess: attempt.isSuccess,
|
||||
isTwoFactor: attempt.isTwoFactor,
|
||||
failureReason: attempt.failureReason,
|
||||
@@ -101,7 +104,7 @@ export class LoginEventsService {
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 웹메일 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
`[SYS_001]${traceTag()} ${attempt.method === 'app' ? '앱' : '웹메일'} 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { credentialNumberToId } from './app-password';
|
||||
import type {
|
||||
LoginEventMethod,
|
||||
LoginFailureReason,
|
||||
SignInMethod,
|
||||
} from './login-event.entity';
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,8 @@ export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required';
|
||||
export interface EngineLoginEvent {
|
||||
readonly dedupeKey: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly method: Exclude<LoginEventMethod, 'webmail'>;
|
||||
/** 메일 프로그램의 방식 — imap · smtp (웹메일 · 앱은 서버가 판정해 남긴다) */
|
||||
readonly method: Exclude<LoginEventMethod, SignInMethod>;
|
||||
readonly outcome: EngineLoginOutcome;
|
||||
/** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */
|
||||
readonly accountName: string;
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('엔진 웹훅 본문 파서 (createMailEngineEventsBodyParser)', () =>
|
||||
success: false,
|
||||
error: {
|
||||
code: 'AUTH_001',
|
||||
message: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
message: '로그인이 필요합니다. 로그인한 뒤 다시 시도해 주세요.',
|
||||
traceId: 'trace-e',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,16 @@ export interface TwoFactorEnrollment {
|
||||
readonly secret: Buffer;
|
||||
/** 끝나는 때 (epoch ms) */
|
||||
readonly expiresAt: number;
|
||||
/** 새 휴대폰의 코드로 확인했다 — 휴대폰 바꾸기(16단계)는 확인한 등록으로만 한다 */
|
||||
readonly verified?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기다리는 2단계 인증 등록 (계약 5절 "2단계 인증") — 직원마다 하나, 10분.
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜면(또는 시간이 지나면) 지운다 — 켠 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 새로 시작하면 앞의 것을 버린다. 켜거나 휴대폰을 바꾸면(또는 시간이 지나면) 지운다 — 그 뒤의 비밀은 엔진에만 있다(서버 · DB · 로그 어디에도 두지 않는다).
|
||||
* 켜기와 휴대폰 바꾸기(16단계 — 켜져 있을 때의 등록)가 같은 자리를 쓴다.
|
||||
*
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기가 409 AUTH_008 이다.
|
||||
* ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 공유 저장소로 옮긴다(10분짜리 항목). 그대로면 다른 인스턴스로 간 켜기 · 바꾸기가 409 AUTH_008 이다.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TwoFactorEnrollments {
|
||||
@@ -45,6 +48,14 @@ export class TwoFactorEnrollments {
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -25,8 +26,12 @@ 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,
|
||||
@@ -50,7 +55,7 @@ function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto {
|
||||
'AUTH_001',
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)',
|
||||
)
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010
|
||||
// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기 · 휴대폰 바꾸기(16단계 — 확인 · 바꾸기)는 403 AUTH_010
|
||||
@Controller('two-factor')
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactor: TwoFactorService) {}
|
||||
@@ -80,18 +85,19 @@ export class TwoFactorController {
|
||||
@AllowRestrictedSession()
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)',
|
||||
summary:
|
||||
'2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있으면 켜기용 · 켜져 있으면 휴대폰 바꾸기용)',
|
||||
description:
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'서버가 160비트 비밀을 만들어 10분 동안 서버 메모리에만 둔다(켜거나 바꾸면 엔진에만 남는다). 직원마다 기다리는 등록은 하나 — 새로 시작하면 앞의 것은 버린다. ' +
|
||||
'16단계부터 켜져 있어도 받는다(휴대폰 바꾸기 — …/verification → /api/two-factor/replacements). 켜진 채 이 등록으로 켜기를 보내면 409 AUTH_008 이다. ' +
|
||||
'응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(TwoFactorEnrollmentDto, {
|
||||
status: 201,
|
||||
description: '등록 — 비밀 · otpauth 주소는 이 응답에만',
|
||||
})
|
||||
@ApiErrorResponse('AUTH_008', '이미 켜져 있음 — 화면은 상태를 다시 받는다')
|
||||
@ApiErrorResponse('REQ_001', '같은 직원의 2단계 인증 바꾸기가 처리 중')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 설정 없음')
|
||||
async enroll(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<TwoFactorEnrollmentDto> {
|
||||
@@ -104,6 +110,93 @@ export class TwoFactorController {
|
||||
};
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AccountSecurityProvider,
|
||||
TwoFactorDisableResult,
|
||||
TwoFactorEnableResult,
|
||||
TwoFactorReplaceResult,
|
||||
} from './account-security.provider';
|
||||
import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import { TwoFactorEnrollments } from './two-factor-enrollments';
|
||||
@@ -114,7 +115,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
let provider: jest.Mocked<
|
||||
Pick<
|
||||
AccountSecurityProvider,
|
||||
'isTwoFactorEnabled' | 'enableTwoFactor' | 'disableTwoFactor'
|
||||
| 'isTwoFactorEnabled'
|
||||
| 'enableTwoFactor'
|
||||
| 'disableTwoFactor'
|
||||
| 'replaceTwoFactor'
|
||||
>
|
||||
>;
|
||||
let mail: { verifyCredentials: jest.Mock };
|
||||
@@ -160,6 +164,10 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
engineEnabled = false;
|
||||
return Promise.resolve('disabled');
|
||||
}),
|
||||
replaceTwoFactor: jest.fn<
|
||||
Promise<TwoFactorReplaceResult>,
|
||||
[string, string, string, string]
|
||||
>(() => Promise.resolve('replaced')),
|
||||
};
|
||||
mail = {
|
||||
verifyCredentials: jest.fn(() =>
|
||||
@@ -376,7 +384,7 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
).toBe('AUTH_008');
|
||||
});
|
||||
|
||||
it('그사이 다른 곳에서 켜졌으면 등록 · 켜기 모두 409 AUTH_008', async () => {
|
||||
it('그사이 다른 곳에서 켜졌으면 켜기는 409 AUTH_008 — 등록은 켜져 있어도 받는다(16단계 휴대폰 바꾸기용 · 엔진에 묻지 않는다)', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
engineEnabled = true;
|
||||
|
||||
@@ -395,9 +403,11 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect((await failureOf(service.startEnrollment(ARA)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
provider.isTwoFactorEnabled.mockClear();
|
||||
await expect(service.startEnrollment(ARA)).resolves.toMatchObject({
|
||||
secret: expect.stringMatching(/^[A-Z2-7]{32}$/) as unknown,
|
||||
});
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('켜기의 결과를 모르면 엔진을 다시 읽어 켜졌으면 성공, 아니면 503 MAIL_001', async () => {
|
||||
@@ -579,6 +589,251 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', (
|
||||
}
|
||||
});
|
||||
|
||||
describe('휴대폰 바꾸기 (16단계 — 켠 채 새 비밀로)', () => {
|
||||
const OLD_CODE = '135790';
|
||||
const enabledAt = new Date('2026-10-31T00:12:00.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
engineEnabled = true;
|
||||
table.rows.set(ARA, { userEmail: ARA, enabledAt });
|
||||
// 엔진 대역 — 지금 휴대폰의 코드는 OLD_CODE 하나만 맞다
|
||||
provider.replaceTwoFactor.mockImplementation((_user, _password, code) =>
|
||||
Promise.resolve(code === OLD_CODE ? 'replaced' : 'code-rejected'),
|
||||
);
|
||||
});
|
||||
|
||||
/** 등록하고 새 휴대폰의 코드로 확인한다 */
|
||||
const verified = async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const newCode = code();
|
||||
await service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: newCode },
|
||||
{ ip: IP },
|
||||
);
|
||||
return { enrollment, newCode, code };
|
||||
};
|
||||
|
||||
const replace = (
|
||||
enrollmentId: string,
|
||||
overrides: { currentOtpCode?: string; currentPassword?: string } = {},
|
||||
ip = IP,
|
||||
) =>
|
||||
service.replace(
|
||||
ARA,
|
||||
{
|
||||
enrollmentId,
|
||||
currentOtpCode: OLD_CODE,
|
||||
currentPassword: PASSWORD,
|
||||
...overrides,
|
||||
},
|
||||
{ ip },
|
||||
);
|
||||
|
||||
it('① 새 휴대폰의 코드를 등록의 비밀로 확인(엔진에 묻지 않는다) → ② 지금 비밀번호(본인 Basic 402) → 지금 코드와 새 주소를 엔진 한 요청 — 켠 때는 그대로, 등록은 지운다', async () => {
|
||||
const { enrollment, newCode } = await verified();
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
expect(codes.isUsed(ARA, newCode)).toBe(true);
|
||||
|
||||
const status = await replace(enrollment.enrollmentId);
|
||||
|
||||
expect(status).toEqual({ isEnabled: true, enabledAt });
|
||||
expect(mail.verifyCredentials).toHaveBeenCalledWith(ARA, PASSWORD);
|
||||
expect(provider.replaceTwoFactor).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
PASSWORD,
|
||||
OLD_CODE,
|
||||
enrollment.otpauthUrl,
|
||||
);
|
||||
expect(codes.isUsed(ARA, OLD_CODE)).toBe(true);
|
||||
expect(table.rows.get(ARA)?.enabledAt).toEqual(enabledAt);
|
||||
expect(logs).toContain(
|
||||
'2단계 인증 휴대폰을 바꿨습니다: a***@arasystem.test',
|
||||
);
|
||||
expect(logs.join('\n')).not.toMatch(
|
||||
new RegExp(`${OLD_CODE}|${newCode}|${PASSWORD}|otpauth`),
|
||||
);
|
||||
// 등록은 지웠다 — 같은 등록으로 다시 바꾸지 못한다
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('확인하지 않은 등록은 409 AUTH_008 — 등록을 버린다(처음부터) · 없는 등록 · 다른 id 도 AUTH_008', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
// 버렸다 — 확인도 할 수 없다
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect(
|
||||
(await failureOf(replace('00000000-0000-4000-8000-000000000000')))
|
||||
?.code,
|
||||
).toBe('AUTH_008');
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('① 새 휴대폰의 코드가 틀리면 400 AUTH_006 — 코드 실패로 센다(15분 5회 → REQ_001), 확인되지 않는다', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const wrong = code() === '000000' ? '111111' : '000000';
|
||||
for (let i = 0; i < TWO_FACTOR_CODE_ADDRESS_LIMIT; i += 1) {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: wrong },
|
||||
{ ip: `198.51.100.${i}` },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('② 지금 비밀번호가 틀리면 400 AUTH_005(로그인 실패로 센다) · 앱 비밀번호 모양은 묻지 않고 AUTH_005 — 엔진에 보내지 않고 등록은 남는다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
mail.verifyCredentials.mockResolvedValueOnce(INVALID);
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentPassword: 'wrong' }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_005');
|
||||
mail.verifyCredentials.mockClear();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, {
|
||||
currentPassword: `app_${'a'.repeat(36)}`,
|
||||
}),
|
||||
)
|
||||
)?.context,
|
||||
).toMatchObject({ reason: 'app-password-shape' });
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
// 등록은 남았다 — 비밀번호만 고쳐 다시 보낸다
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('② 그사이 다른 곳에서 꺼졌으면(본인 Basic 200) 409 AUTH_008 — 엔진에 보내지 않고 등록을 버린다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
engineEnabled = false;
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
engineEnabled = true;
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'AUTH_008',
|
||||
);
|
||||
});
|
||||
|
||||
it('② 엔진이 지금 코드를 받지 않으면(forbidden) 400 AUTH_006 — 코드 실패로 센다, 등록은 남아 맞는 코드로 다시 보낼 수 있다', async () => {
|
||||
const { enrollment, newCode } = await verified();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentOtpCode: '246800' }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
// 다시 쓴 코드(①에서 쓴 새 휴대폰의 코드)는 엔진에 묻지 않고 AUTH_006
|
||||
provider.replaceTwoFactor.mockClear();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
replace(enrollment.enrollmentId, { currentOtpCode: newCode }),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
expect(provider.replaceTwoFactor).not.toHaveBeenCalled();
|
||||
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('② 결과를 모르면 503 MAIL_001 — 다시 읽지 않는다(가를 수 없다), 코드는 쓰지 않은 것으로, 등록은 남는다', async () => {
|
||||
const { enrollment } = await verified();
|
||||
provider.replaceTwoFactor.mockResolvedValueOnce('unknown');
|
||||
provider.isTwoFactorEnabled.mockClear();
|
||||
|
||||
const failure = await failureOf(replace(enrollment.enrollmentId));
|
||||
expect(failure?.code).toBe('MAIL_001');
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'two-factor-replace-outcome-unknown',
|
||||
});
|
||||
expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled();
|
||||
expect(codes.isUsed(ARA, OLD_CODE)).toBe(false);
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toMatchObject({
|
||||
isEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('같은 직원의 바꾸기가 처리 중이면 확인 · 바꾸기 모두 429 REQ_001', async () => {
|
||||
const { enrollment, code } = await enroll();
|
||||
const release = locks.acquire(ARA);
|
||||
try {
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.verifyEnrollment(
|
||||
ARA,
|
||||
{ enrollmentId: enrollment.enrollmentId, otpCode: code() },
|
||||
{ ip: IP },
|
||||
),
|
||||
)
|
||||
)?.code,
|
||||
).toBe('REQ_001');
|
||||
expect((await failureOf(replace(enrollment.enrollmentId)))?.code).toBe(
|
||||
'REQ_001',
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
it('켠 때 기록을 읽지 못해도 바꾸기는 성공 — enabledAt null · error 로그', async () => {
|
||||
const { enrollment } = await verified();
|
||||
table.repository.findOneBy.mockRejectedValueOnce(new Error('db down'));
|
||||
await expect(replace(enrollment.enrollmentId)).resolves.toEqual({
|
||||
isEnabled: true,
|
||||
enabledAt: null,
|
||||
});
|
||||
expect(errors.join('\n')).toContain('켠 때 기록을 읽지 못했습니다');
|
||||
});
|
||||
});
|
||||
|
||||
describe('끄기', () => {
|
||||
beforeEach(() => {
|
||||
engineEnabled = true;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TwoFactorCodeGuard } from './two-factor-code-guard';
|
||||
import {
|
||||
TwoFactorEnrollments,
|
||||
enrollmentExpiry,
|
||||
type TwoFactorEnrollment,
|
||||
} from './two-factor-enrollments';
|
||||
import { TwoFactorLocks } from './two-factor-locks';
|
||||
import { TwoFactorPolicyService } from './two-factor-policy.service';
|
||||
@@ -74,7 +75,9 @@ function errorMessage(error: unknown): string {
|
||||
/**
|
||||
* 2단계 인증 켜기 · 끄기 (계약 5절 "2단계 인증") — **엔진이 기준이다**(켜짐은 엔진의 x:AccountPassword 로 본다).
|
||||
*
|
||||
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 이미 켜져 있으면 409 AUTH_008
|
||||
* - 등록: 서버가 160비트 비밀을 만들어 10분 동안 메모리에만 둔다 (TwoFactorEnrollments). 켜져 있어도 받는다 — 휴대폰 바꾸기용(16단계)
|
||||
* - 휴대폰 바꾸기(16단계): ① 새 휴대폰의 코드를 등록의 비밀로 확인해 등록에 "확인됨"을 단다(verifyEnrollment) ② 지금 비밀번호(본인 Basic 402) →
|
||||
* 지금 휴대폰의 코드와 새 otpauth 주소를 엔진 한 요청으로(replace — 2단계가 꺼지는 틈이 없다). 결과를 모르면 MAIL_001(다시 읽어도 가를 수 없다)
|
||||
* - 켜기: 받은 코드를 등록의 비밀로 **먼저** 확인한다(±1 단계 — 엔진은 등록 때 코드를 보지 않는다, 잘못 등록하면 직원이 끄지도 못한다).
|
||||
* 둘 다 틀렸으면 코드부터 알린다. 그다음 지금 비밀번호를 본인 Basic 으로 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다,
|
||||
* 402 면 그사이 다른 곳에서 켜짐 → AUTH_008) 대리 접근으로 엔진에 otpauth 주소를 넣는다.
|
||||
@@ -134,18 +137,13 @@ export class TwoFactorService {
|
||||
return this.reconcile(address, enabled);
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/enrollments — 꺼져 있을 때만 */
|
||||
/**
|
||||
* POST /api/two-factor/enrollments — 꺼져 있으면 켜기용, 켜져 있으면 휴대폰 바꾸기용(16단계) — 같은 등록이다.
|
||||
* 엔진에 묻지 않는다: 켜기는 비밀번호 확인(402)이, 바꾸기는 엔진이 지금 코드로 가른다
|
||||
*/
|
||||
async startEnrollment(user: string): Promise<TwoFactorEnrollmentView> {
|
||||
const address = this.requireEmployee(user);
|
||||
return this.locks.run(address, async () => {
|
||||
if (await this.provider.isTwoFactorEnabled(address)) {
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-already-enabled',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.locks.run(address, () => {
|
||||
const now = Date.now();
|
||||
const enrollment = {
|
||||
id: randomUUID(),
|
||||
@@ -154,15 +152,181 @@ export class TwoFactorService {
|
||||
};
|
||||
this.enrollments.put(address, enrollment);
|
||||
const secret = encodeBase32(enrollment.secret);
|
||||
return {
|
||||
return Promise.resolve({
|
||||
enrollmentId: enrollment.id,
|
||||
secret,
|
||||
otpauthUrl: this.otpauthUrl(address, secret),
|
||||
expiresAt: new Date(enrollment.expiresAt),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/two-factor/enrollments/{id}/verification — 새 휴대폰의 코드를 등록의 비밀로 확인하고 등록에 "확인됨"을 단다 (16단계 휴대폰 바꾸기 ①).
|
||||
* 엔진은 등록 때 코드를 보지 않으므로 서버가 먼저 본다. 코드 확인은 켜기 · 끄기와 같은 코드 가드(실패 한도 · 2분 다시 쓰기 막기 — 받은 코드는 기억한다).
|
||||
* 등록이 없음 · 10분 지남 · 더 새 등록이면 AUTH_008, 코드 틀림 AUTH_006. 엔진에 묻지 않는다
|
||||
*/
|
||||
async verifyEnrollment(
|
||||
user: string,
|
||||
input: { readonly enrollmentId: string; readonly otpCode: string },
|
||||
client: SecurityClient,
|
||||
): Promise<void> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
const enrollment = this.requireEnrollment(address, input.enrollmentId);
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.otpCode,
|
||||
() =>
|
||||
Promise.resolve(
|
||||
verifyTotp(enrollment.secret, input.otpCode, Date.now()) === null
|
||||
? { kind: 'rejected' as const }
|
||||
: { kind: 'accepted' as const, value: undefined },
|
||||
),
|
||||
);
|
||||
if (!this.enrollments.markVerified(address, input.enrollmentId)) {
|
||||
// 코드를 보는 사이 10분이 지났다
|
||||
throw this.enrollmentMissing(address, 'two-factor-enrollment-expired');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/two-factor/replacements — 2단계 인증을 켠 채 휴대폰을 바꾼다 (16단계 ②). 확인한 등록 · 지금 휴대폰의 코드 · 지금 비밀번호.
|
||||
* 1. 등록이 확인됨이 아니면 AUTH_008(등록을 버린다 — 처음부터)
|
||||
* 2. 지금 비밀번호를 본인 Basic 으로 — 402 여야 한다(켜짐 · 비밀번호 맞음). 401 · 앱 비밀번호 모양은 AUTH_005(로그인 실패 한도로 센다 — 모양은 세지 않는다),
|
||||
* 200 이면 그사이 다른 곳에서 꺼졌다 → AUTH_008(등록을 버린다). 엔진은 비밀번호 틀림과 코드 틀림을 가르지 않아 비밀번호를 먼저 본다
|
||||
* 3. 다시 쓴 코드 · 코드 실패 차지(끄기와 같은 결) → 엔진 한 요청(지금 코드 + 새 otpauth 주소). forbidden 은 지금 코드 틀림 AUTH_006,
|
||||
* 결과를 모르면 MAIL_001(엔진은 otpUrl 을 가려 다시 읽어도 가를 수 없다 — 그 코드는 쓰지 않은 것으로, 등록은 남긴다)
|
||||
* 4. 바꾸면 등록의 비밀을 지운다. 켠 때(enabledAt)는 그대로. 로그인된 기기는 끊지 않는다(켜기 · 끄기와 같다)
|
||||
*/
|
||||
async replace(
|
||||
user: string,
|
||||
input: {
|
||||
readonly enrollmentId: string;
|
||||
readonly currentOtpCode: string;
|
||||
readonly currentPassword: string;
|
||||
},
|
||||
client: SecurityClient,
|
||||
): Promise<TwoFactorStatus> {
|
||||
const address = this.requireEmployee(user);
|
||||
const ip = client.ip || 'unknown';
|
||||
return this.locks.run(address, async () => {
|
||||
const enrollment = this.requireEnrollment(address, input.enrollmentId);
|
||||
if (enrollment.verified !== true) {
|
||||
this.enrollments.forget(address);
|
||||
throw this.enrollmentMissing(
|
||||
address,
|
||||
'two-factor-enrollment-unverified',
|
||||
);
|
||||
}
|
||||
if (isAppPasswordShape(input.currentPassword)) {
|
||||
throw this.currentPasswordRejected(address, 'app-password-shape');
|
||||
}
|
||||
const otpauthUrl = this.otpauthUrl(
|
||||
address,
|
||||
encodeBase32(enrollment.secret),
|
||||
);
|
||||
|
||||
const ticket = this.attempts.begin({ email: address, ip });
|
||||
try {
|
||||
const check = await this.mail.verifyCredentials(
|
||||
address,
|
||||
input.currentPassword,
|
||||
);
|
||||
if (check.kind === 'invalid') {
|
||||
ticket.fail();
|
||||
throw this.currentPasswordRejected(
|
||||
address,
|
||||
'current-password-rejected',
|
||||
);
|
||||
}
|
||||
if (check.kind === 'valid') {
|
||||
// 엔진에서 그사이 꺼졌다 — 바꿀 것이 없다. 처음부터(켜기)
|
||||
ticket.succeed();
|
||||
this.enrollments.forget(address);
|
||||
throw new BusinessException('AUTH_008', {
|
||||
context: {
|
||||
reason: 'two-factor-disabled-elsewhere',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 402 — 켜져 있고 비밀번호가 맞다. 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다
|
||||
ticket.verified();
|
||||
|
||||
await this.codes.check<undefined>(
|
||||
{ email: address, ip },
|
||||
input.currentOtpCode,
|
||||
async () => {
|
||||
const outcome = await this.provider.replaceTwoFactor(
|
||||
address,
|
||||
input.currentPassword,
|
||||
input.currentOtpCode,
|
||||
otpauthUrl,
|
||||
);
|
||||
if (outcome === 'code-rejected')
|
||||
return { kind: 'rejected' as const };
|
||||
if (outcome === 'unknown') {
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: {
|
||||
reason: 'two-factor-replace-outcome-unknown',
|
||||
email: maskEmail(address),
|
||||
},
|
||||
});
|
||||
}
|
||||
return { kind: 'accepted' as const, value: undefined };
|
||||
},
|
||||
);
|
||||
ticket.succeed();
|
||||
} finally {
|
||||
ticket.abort();
|
||||
}
|
||||
|
||||
// 엔진에서 바뀌었다 — 등록의 비밀을 지운다
|
||||
this.enrollments.forget(address);
|
||||
this.logger.log(`2단계 인증 휴대폰을 바꿨습니다: ${maskEmail(address)}`);
|
||||
return { isEnabled: true, enabledAt: await this.readEnabledAt(address) };
|
||||
});
|
||||
}
|
||||
|
||||
/** 기다리는 등록 — 없음 · 10분 지남 · 더 새 등록이면 AUTH_008 */
|
||||
private requireEnrollment(
|
||||
address: string,
|
||||
enrollmentId: string,
|
||||
): TwoFactorEnrollment {
|
||||
const enrollment = this.enrollments.find(address, enrollmentId);
|
||||
if (!enrollment) {
|
||||
throw this.enrollmentMissing(address, 'two-factor-enrollment-missing');
|
||||
}
|
||||
return enrollment;
|
||||
}
|
||||
|
||||
private enrollmentMissing(
|
||||
address: string,
|
||||
reason: string,
|
||||
): BusinessException {
|
||||
return new BusinessException('AUTH_008', {
|
||||
context: { reason, email: maskEmail(address) },
|
||||
});
|
||||
}
|
||||
|
||||
/** 켠 때 기록 — 읽지 못하면 error 로그만 남기고 null (엔진에서는 이미 바뀌었다) */
|
||||
private async readEnabledAt(address: string): Promise<Date | null> {
|
||||
try {
|
||||
return (
|
||||
(await this.settings.findOneBy({ userEmail: address }))?.enabledAt ??
|
||||
null
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 2단계 인증 휴대폰을 바꿨지만 켠 때 기록을 읽지 못했습니다(엔진에서는 바뀌었다): user=${maskEmail(address)}, reason=${errorMessage(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/two-factor/activation */
|
||||
async activate(
|
||||
user: string,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 앱 설정 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `GET /api/app-config`).
|
||||
*
|
||||
* 앱이 쓸 수 있는 가장 낮은 판 — 플랫폼마다. 이보다 낮은 앱은 어느 화면에서든 업데이트 화면을 보인다(앱 몫).
|
||||
* 옛 앱이 쓸 수 없게 서버 API 를 바꾸는 배포가 이 값을 올린다 — **같은 커밋에서** 올린다(배포와 함께 움직이게 env 가 아니라 코드 상수다).
|
||||
* ios 가 null 이면 아직 iOS 앱이 없다는 뜻이다(막지 않는다)
|
||||
*/
|
||||
export const MIN_SUPPORTED_APP_VERSION = {
|
||||
android: '1.0.0',
|
||||
ios: null,
|
||||
} as const satisfies Readonly<Record<'android' | 'ios', string | null>>;
|
||||
|
||||
/** 앱 설정 응답의 캐시 — 공유 캐시 · 휴대폰에 남기지 않는다(판을 올린 배포가 바로 보이게) */
|
||||
export const APP_CONFIG_CACHE_CONTROL = 'no-store';
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { PushSender } from '@/modules/push/push-sender.port';
|
||||
import { AppConfigController } from './app-config.controller';
|
||||
|
||||
const senderOf = (available: boolean): PushSender => ({
|
||||
available,
|
||||
send: () => Promise.resolve('failed'),
|
||||
});
|
||||
|
||||
describe('AppConfigController (GET /api/app-config — 로그인 없음)', () => {
|
||||
it('플랫폼마다 가장 낮은 판(ios 는 아직 null) · 알림을 보낼 수 있는지', () => {
|
||||
expect(new AppConfigController(senderOf(true)).get()).toEqual({
|
||||
minSupportedVersion: { android: '1.0.0', ios: null },
|
||||
pushAvailable: true,
|
||||
});
|
||||
expect(new AppConfigController(senderOf(false)).get().pushAvailable).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('응답은 상수를 고치지 못하는 새 객체다', () => {
|
||||
const controller = new AppConfigController(senderOf(true));
|
||||
const first = controller.get();
|
||||
(first.minSupportedVersion as { android: string }).android = '9.9.9';
|
||||
expect(controller.get().minSupportedVersion.android).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Header, Inject } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiStandardResponse } from '@/common/decorators/api-standard-response.decorator';
|
||||
import { PUSH_SENDER, type PushSender } from '@/modules/push/push-sender.port';
|
||||
import {
|
||||
APP_CONFIG_CACHE_CONTROL,
|
||||
MIN_SUPPORTED_APP_VERSION,
|
||||
} from './app-config.constants';
|
||||
import { AppConfigDto } from './dto/app-config.dto';
|
||||
|
||||
/**
|
||||
* 앱 설정 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 로그인 없이 받는다(로그인 화면 전에 본다).
|
||||
* 가드가 없어 제한된 세션에서도 그대로 부른다. 요청 수는 전역 기본 한도(경로마다 IP 당 1분 100회 — 문의 연락처와 같다).
|
||||
* 응답은 Cache-Control: no-store — 판을 올린 배포가 바로 보이게
|
||||
*/
|
||||
@ApiTags('App')
|
||||
@Controller('app-config')
|
||||
export class AppConfigController {
|
||||
constructor(@Inject(PUSH_SENDER) private readonly sender: PushSender) {}
|
||||
|
||||
@Get()
|
||||
@Header('Cache-Control', APP_CONFIG_CACHE_CONTROL)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'앱 설정 (로그인 없음) — 앱이 쓸 수 있는 가장 낮은 판 · 앱 알림을 보낼 수 있는지',
|
||||
description:
|
||||
'minSupportedVersion 은 플랫폼마다(ios 는 아직 null — 막지 않는다). pushAvailable 은 서버가 FCM 키를 읽었는지다. ' +
|
||||
'앱은 띄울 때 한 번 · 앞으로 올 때 1시간이 지났으면 부른다. 실패 · 모양 틀림이면 막지 않는다. 응답은 Cache-Control: no-store.',
|
||||
})
|
||||
@ApiStandardResponse(AppConfigDto, { description: '앱 설정' })
|
||||
get(): AppConfigDto {
|
||||
return {
|
||||
minSupportedVersion: { ...MIN_SUPPORTED_APP_VERSION },
|
||||
pushAvailable: this.sender.available,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PushModule } from '@/modules/push/push.module';
|
||||
import { AppConfigController } from './app-config.controller';
|
||||
|
||||
/**
|
||||
* 앱 설정 모듈 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `GET /api/app-config`) — 최소 판(코드 상수)과 앱 알림을 보낼 수 있는지(PUSH_SENDER).
|
||||
* 이 모듈은 푸시 모듈만 가져온다
|
||||
*/
|
||||
@Module({
|
||||
imports: [PushModule],
|
||||
controllers: [AppConfigController],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** 플랫폼마다 앱이 쓸 수 있는 가장 낮은 판 */
|
||||
export class MinSupportedVersionDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'안드로이드 앱의 가장 낮은 판 — 앱은 판(major.minor.patch)을 칸마다 숫자로 견줘 이보다 낮으면 업데이트 화면을 보인다',
|
||||
example: '1.0.0',
|
||||
})
|
||||
android!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'iOS 앱의 가장 낮은 판 — 아직 iOS 앱이 없어 null(막지 않는다)',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: null,
|
||||
})
|
||||
ios!: string | null;
|
||||
}
|
||||
|
||||
/** GET /api/app-config 의 응답 */
|
||||
export class AppConfigDto {
|
||||
@ApiProperty({
|
||||
description: '플랫폼마다 앱이 쓸 수 있는 가장 낮은 판',
|
||||
type: MinSupportedVersionDto,
|
||||
})
|
||||
minSupportedVersion!: MinSupportedVersionDto;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'서버가 앱 알림을 보낼 수 있다(FCM 키가 있다). false 면 앱은 알림 칸을 "쓸 수 없음"으로 보인다',
|
||||
example: true,
|
||||
})
|
||||
pushAvailable!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
truncateSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { forEachChunk, inspectApk, type ApkInspection } from './apk-file';
|
||||
import {
|
||||
APK_MAX_BYTES,
|
||||
APK_MIN_BYTES,
|
||||
APK_ZIP_SIGNATURE,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/** zip 머리 + 무작위 내용 */
|
||||
function apkBytes(size: number): Buffer {
|
||||
return Buffer.concat([
|
||||
APK_ZIP_SIGNATURE,
|
||||
randomBytes(size - APK_ZIP_SIGNATURE.length),
|
||||
]);
|
||||
}
|
||||
|
||||
const sha256 = (data: Buffer) =>
|
||||
createHash('sha256').update(data).digest('hex');
|
||||
|
||||
/** 검사가 막은 까닭 (통과했으면 연 파일을 닫고 'ok') */
|
||||
async function problemOf(inspection: ApkInspection): Promise<string> {
|
||||
if (inspection.ok) {
|
||||
await inspection.apk.close();
|
||||
return 'ok';
|
||||
}
|
||||
return inspection.problem;
|
||||
}
|
||||
|
||||
/** 이 시험을 root 로 돌리는가 — root 는 권한과 상관없이 읽는다 */
|
||||
const runningAsRoot = process.getuid?.() === 0;
|
||||
const onWindows = process.platform === 'win32';
|
||||
|
||||
describe('inspectApk — 운영자가 넣은 APK 검사 (휴대폰 앱 올리기)', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'apk-file-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
const file = (name: string, data: Buffer) => {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, data);
|
||||
return path;
|
||||
};
|
||||
|
||||
it('통과하면 크기 · SHA-256 · 파일 이름과 연 파일을 준다 — 연 파일로 다시 읽을 수 있다', async () => {
|
||||
const data = apkBytes(64 * 1024);
|
||||
const path = file('ARA-SYSTEM-1.0.0.apk', data);
|
||||
|
||||
const inspection = await inspectApk(path);
|
||||
expect(inspection.ok).toBe(true);
|
||||
if (!inspection.ok) return;
|
||||
const { apk } = inspection;
|
||||
try {
|
||||
expect(apk).toMatchObject({
|
||||
path,
|
||||
fileName: 'ARA-SYSTEM-1.0.0.apk',
|
||||
sizeBytes: data.length,
|
||||
sha256: sha256(data),
|
||||
});
|
||||
const again = Buffer.alloc(data.length);
|
||||
await apk.handle.read(again, 0, again.length, 0);
|
||||
expect(again.equals(data)).toBe(true);
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('이름은 .apk 로 끝나야 한다 (대소문자는 가리지 않는다) — 이름이 틀리면 열지도 않는다', async () => {
|
||||
const data = apkBytes(APK_MIN_BYTES);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('ARA-SYSTEM-1.0.0.APK', data))),
|
||||
).resolves.toBe('ok');
|
||||
for (const name of ['ARA-SYSTEM-1.0.0.zip', 'ARA-SYSTEM-1.0.0', 'apk']) {
|
||||
await expect(problemOf(await inspectApk(file(name, data)))).resolves.toBe(
|
||||
'not-apk-name',
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'none.zip'))),
|
||||
).resolves.toBe('not-apk-name');
|
||||
});
|
||||
|
||||
it('없는 경로 · 폴더 · 파일이 아닌 것은 알린다', async () => {
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'none.apk'))),
|
||||
).resolves.toBe('missing');
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'no-dir', 'x.apk'))),
|
||||
).resolves.toBe('missing');
|
||||
|
||||
mkdirSync(join(dir, 'folder.apk'));
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'folder.apk'))),
|
||||
).resolves.toBe('not-file');
|
||||
});
|
||||
|
||||
(onWindows ? it.skip : it)(
|
||||
'바로가기(심볼릭 링크)는 따라가지 않는다',
|
||||
async () => {
|
||||
const target = file('real.apk', apkBytes(APK_MIN_BYTES));
|
||||
symlinkSync(target, join(dir, 'link.apk'));
|
||||
await expect(
|
||||
problemOf(await inspectApk(join(dir, 'link.apk'))),
|
||||
).resolves.toBe('symlink');
|
||||
},
|
||||
);
|
||||
|
||||
(onWindows ? it.skip : it)(
|
||||
'파이프(FIFO)를 열 때 멈추지 않는다 — 일반 파일이 아니라고 알린다',
|
||||
async () => {
|
||||
const path = join(dir, 'pipe.apk');
|
||||
try {
|
||||
execFileSync('mkfifo', [path]);
|
||||
} catch {
|
||||
// mkfifo 가 없는 환경 — 이 시험은 건너뛴다 (개발 컨테이너 · CI 에는 있다)
|
||||
return;
|
||||
}
|
||||
await expect(problemOf(await inspectApk(path))).resolves.toBe('not-file');
|
||||
},
|
||||
);
|
||||
|
||||
(onWindows || runningAsRoot ? it.skip : it)(
|
||||
'읽을 권한이 없으면 알린다 (docker cp 는 root 소유로 넣는다)',
|
||||
async () => {
|
||||
const path = file('locked.apk', apkBytes(APK_MIN_BYTES));
|
||||
chmodSync(path, 0o000);
|
||||
await expect(problemOf(await inspectApk(path))).resolves.toBe(
|
||||
'unreadable',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('크기 — 1KB 아래 · 200MB 위는 받지 않는다(큰 파일은 읽지 않고 크기로만), 경계는 받는다', async () => {
|
||||
await expect(
|
||||
problemOf(
|
||||
await inspectApk(file('small.apk', apkBytes(APK_MIN_BYTES - 1))),
|
||||
),
|
||||
).resolves.toBe('too-small');
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('edge.apk', apkBytes(APK_MIN_BYTES)))),
|
||||
).resolves.toBe('ok');
|
||||
|
||||
// 빈 곳만 있는 큰 파일(디스크를 쓰지 않는다) — 머리를 읽기 전에 크기로 막는다
|
||||
const large = join(dir, 'large.apk');
|
||||
writeFileSync(large, APK_ZIP_SIGNATURE);
|
||||
truncateSync(large, APK_MAX_BYTES + 1);
|
||||
await expect(problemOf(await inspectApk(large))).resolves.toBe('too-large');
|
||||
});
|
||||
|
||||
it('zip 머리(PK\\x03\\x04)가 아니면 APK 가 아니다', async () => {
|
||||
const notZip = Buffer.concat([
|
||||
Buffer.from('<html>'),
|
||||
randomBytes(APK_MIN_BYTES),
|
||||
]);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('page.apk', notZip))),
|
||||
).resolves.toBe('not-zip');
|
||||
// 빈 디렉터리 머리(PK\x05\x06 — 빈 zip)도 APK 가 아니다
|
||||
const emptyZip = Buffer.concat([
|
||||
Buffer.from([0x50, 0x4b, 0x05, 0x06]),
|
||||
Buffer.alloc(APK_MIN_BYTES),
|
||||
]);
|
||||
await expect(
|
||||
problemOf(await inspectApk(file('empty.apk', emptyZip))),
|
||||
).resolves.toBe('not-zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEachChunk — 연 파일을 처음부터 끝까지', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'apk-chunks-'));
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('조각을 차례로 넘기고 읽은 크기가 맞으면 true — 다르면(그사이 바뀜) false', async () => {
|
||||
const data = randomBytes(3 * 1024 * 1024 + 17);
|
||||
const path = join(dir, 'data.bin');
|
||||
writeFileSync(path, data);
|
||||
const handle = await open(path, 'r');
|
||||
try {
|
||||
const parts: Buffer[] = [];
|
||||
await expect(
|
||||
forEachChunk(handle, data.length, (chunk) => {
|
||||
parts.push(Buffer.from(chunk));
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(Buffer.concat(parts).equals(data)).toBe(true);
|
||||
expect(parts.length).toBeGreaterThan(1);
|
||||
|
||||
// 생각한 것보다 짧다(줄었다) · 길다(늘었다) — 넘게 읽히면 거기서 멈춘다(끝까지 읽지 않는다)
|
||||
await expect(
|
||||
forEachChunk(handle, data.length + 1, () => undefined),
|
||||
).resolves.toBe(false);
|
||||
let seen = 0;
|
||||
await expect(
|
||||
forEachChunk(handle, 10, (chunk) => {
|
||||
seen += chunk.length;
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(seen).toBeLessThanOrEqual(10);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { basename, extname } from 'path';
|
||||
|
||||
import {
|
||||
APK_EXTENSION,
|
||||
APK_MAX_BYTES,
|
||||
APK_MIN_BYTES,
|
||||
APK_ZIP_SIGNATURE,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/**
|
||||
* 운영자가 컨테이너 안에 넣은 APK 읽기 (운영 도구 「휴대폰 앱 올리기」 — 계약 1절 "휴대폰 앱 받기").
|
||||
* 파일 하나를 한 번 열고, 그 열린 파일(같은 inode)로 검사 · SHA-256 · 저장소에 들이기를 모두 한다 —
|
||||
* 검사한 뒤 경로가 다른 파일로 바뀌어도 들이는 것은 검사한 그 파일이다(내용이 바뀌면 들일 때 다시 센 SHA-256 이 막는다)
|
||||
*/
|
||||
|
||||
/** 읽을 수 없는 까닭 — 운영 도구가 한국어로 알린다 (modules/ops/app-release-commands.ts) */
|
||||
export type ApkProblem =
|
||||
/** 이름이 .apk 로 끝나지 않는다 */
|
||||
| 'not-apk-name'
|
||||
/** 그 경로에 없다 */
|
||||
| 'missing'
|
||||
/** 바로가기(심볼릭 링크)다 — 따라가지 않는다 */
|
||||
| 'symlink'
|
||||
/** 일반 파일이 아니다 (폴더 · 장치 · 파이프 · 소켓) */
|
||||
| 'not-file'
|
||||
/** 읽을 권한이 없다 (docker cp 는 root 소유로 넣는다 — 원본이 644 가 아니면 node 가 읽지 못한다) */
|
||||
| 'unreadable'
|
||||
| 'too-small'
|
||||
| 'too-large'
|
||||
/** zip 머리(PK\x03\x04)가 아니다 */
|
||||
| 'not-zip'
|
||||
/** 읽는 동안 크기가 바뀌었다 (아직 복사 중) */
|
||||
| 'changed';
|
||||
|
||||
/** 검사를 마친 APK — 다 쓰면 close() (부르는 쪽이 닫는다) */
|
||||
export interface InspectedApk {
|
||||
/** 운영자가 넣은 경로 그대로 — 실행 기록 · 바뀔 것에 보인다(감사용) */
|
||||
readonly path: string;
|
||||
/** 경로의 마지막 이름 — 판을 짐작하는 데만 쓴다(디스크 · 내려받는 이름이 되지 않는다) */
|
||||
readonly fileName: string;
|
||||
readonly sizeBytes: number;
|
||||
/** SHA-256 (소문자 16진수) */
|
||||
readonly sha256: string;
|
||||
/** 연 파일 — 저장소에 들일 때 처음부터 다시 읽는다 */
|
||||
readonly handle: FileHandle;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ApkInspection =
|
||||
| { readonly ok: true; readonly apk: InspectedApk }
|
||||
| { readonly ok: false; readonly problem: ApkProblem };
|
||||
|
||||
/** 한 번에 읽는 양 */
|
||||
const READ_CHUNK_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 열기 — 바로가기를 따라가지 않고(O_NOFOLLOW — 그 경로가 링크면 ELOOP), 파이프 · 장치를 열 때 멈추지 않게(O_NONBLOCK — 일반 파일 읽기에는 영향이 없다).
|
||||
* 일반 파일인지는 연 뒤 fstat 으로 본다. Windows 에는 두 표시가 없다(0)
|
||||
*/
|
||||
const OPEN_FLAGS =
|
||||
constants.O_RDONLY |
|
||||
(constants.O_NOFOLLOW ?? 0) |
|
||||
(constants.O_NONBLOCK ?? 0);
|
||||
|
||||
function problem(kind: ApkProblem): ApkInspection {
|
||||
return { ok: false, problem: kind };
|
||||
}
|
||||
|
||||
/** 열지 못한 까닭 → 알릴 것 (모르는 오류는 null — 그대로 던진다) */
|
||||
function openProblem(error: unknown): ApkProblem | null {
|
||||
switch ((error as NodeJS.ErrnoException | null)?.code) {
|
||||
case 'ENOENT':
|
||||
case 'ENOTDIR':
|
||||
return 'missing';
|
||||
case 'ELOOP':
|
||||
return 'symlink';
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return 'unreadable';
|
||||
// 소켓 등 — 열 수 없는 특수 파일
|
||||
case 'ENXIO':
|
||||
case 'EISDIR':
|
||||
return 'not-file';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연 파일을 처음부터 끝까지 차례로 읽어 조각마다 부른다 — 읽은 바이트 수가 `size` 와 다르면(그사이 파일이 바뀜) false.
|
||||
* 넘게 읽히면 거기서 멈춘다. 조각 버퍼는 다음 읽기에 다시 쓴다 — 부르는 쪽은 그 안에서 다 쓴다(해시 · 쓰기를 기다린 뒤 돌아온다)
|
||||
*/
|
||||
export async function forEachChunk(
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
onChunk: (chunk: Buffer) => Promise<void> | void,
|
||||
): Promise<boolean> {
|
||||
const buffer = Buffer.alloc(Math.max(1, Math.min(READ_CHUNK_BYTES, size)));
|
||||
let position = 0;
|
||||
for (;;) {
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
||||
if (bytesRead === 0) return position === size;
|
||||
position += bytesRead;
|
||||
if (position > size) return false;
|
||||
await onChunk(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
}
|
||||
|
||||
/** 열린 파일의 SHA-256 — 크기가 바뀌었으면 null */
|
||||
async function sha256Of(
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
): Promise<string | null> {
|
||||
const hash = createHash('sha256');
|
||||
const complete = await forEachChunk(handle, size, (chunk) => {
|
||||
hash.update(chunk);
|
||||
});
|
||||
return complete ? hash.digest('hex') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 넣은 경로의 APK 를 검사한다 — 이름(.apk) · 일반 파일 · 크기(1KB ~ 200MB) · zip 머리 · SHA-256.
|
||||
* 큰 파일은 크기를 본 뒤에야 읽는다(200MB 를 넘는 파일은 읽지 않는다). 통과하면 연 파일째 돌려준다 — 부르는 쪽이 닫는다
|
||||
*/
|
||||
export async function inspectApk(path: string): Promise<ApkInspection> {
|
||||
if (extname(path).toLowerCase() !== APK_EXTENSION) {
|
||||
return problem('not-apk-name');
|
||||
}
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(path, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
const reason = openProblem(error);
|
||||
if (reason === null) throw error;
|
||||
return problem(reason);
|
||||
}
|
||||
let handedOver = false;
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) return problem('not-file');
|
||||
if (stats.size < APK_MIN_BYTES) return problem('too-small');
|
||||
if (stats.size > APK_MAX_BYTES) return problem('too-large');
|
||||
|
||||
const head = Buffer.alloc(APK_ZIP_SIGNATURE.length);
|
||||
const { bytesRead } = await handle.read(head, 0, head.length, 0);
|
||||
if (bytesRead !== head.length || !head.equals(APK_ZIP_SIGNATURE)) {
|
||||
return problem('not-zip');
|
||||
}
|
||||
|
||||
const sha256 = await sha256Of(handle, stats.size);
|
||||
if (sha256 === null) return problem('changed');
|
||||
|
||||
handedOver = true;
|
||||
const opened = handle;
|
||||
return {
|
||||
ok: true,
|
||||
apk: {
|
||||
path,
|
||||
fileName: basename(path),
|
||||
sizeBytes: stats.size,
|
||||
sha256,
|
||||
handle: opened,
|
||||
close: () => opened.close(),
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
if (!handedOver) await handle.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { open, type FileHandle } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
AppReleaseFileChangedError,
|
||||
AppReleaseStorage,
|
||||
} from './app-release-storage';
|
||||
|
||||
const KEY = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
|
||||
const OTHER_KEY = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
|
||||
const sha256 = (data: Buffer) =>
|
||||
createHash('sha256').update(data).digest('hex');
|
||||
|
||||
describe('AppReleaseStorage (휴대폰 앱 파일 — 업로드 볼륨의 app-releases/)', () => {
|
||||
let root: string;
|
||||
let dir: string;
|
||||
let storage: AppReleaseStorage;
|
||||
let source: FileHandle;
|
||||
const data = randomBytes(2 * 1024 * 1024 + 5);
|
||||
|
||||
beforeEach(async () => {
|
||||
root = mkdtempSync(join(tmpdir(), 'app-release-storage-'));
|
||||
// 아직 없는 하위 폴더 — 처음 들일 때 만든다
|
||||
dir = join(root, 'app-releases');
|
||||
storage = new AppReleaseStorage(dir);
|
||||
const sourcePath = join(root, 'ARA-SYSTEM-1.0.0.apk');
|
||||
writeFileSync(sourcePath, data);
|
||||
source = await open(sourcePath, 'r');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await source.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const expected = () => ({ sizeBytes: data.length, sha256: sha256(data) });
|
||||
|
||||
it('들이면 <key>.apk 만 남는다(임시 파일 없음 · 소유자만 쓴다) — 열고 · 있는지 보고 · 지운다', async () => {
|
||||
await storage.importFile(source, KEY, expected());
|
||||
|
||||
expect(readdirSync(dir)).toEqual([`${KEY}.apk`]);
|
||||
expect(readFileSync(join(dir, `${KEY}.apk`)).equals(data)).toBe(true);
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(join(dir, `${KEY}.apk`)).mode & 0o777).toBe(0o640);
|
||||
}
|
||||
|
||||
const opened = await storage.open(KEY);
|
||||
expect(opened?.sizeBytes).toBe(data.length);
|
||||
await opened?.handle.close();
|
||||
await expect(storage.exists(KEY)).resolves.toBe(true);
|
||||
|
||||
await storage.remove(KEY);
|
||||
await storage.remove(KEY);
|
||||
await expect(storage.exists(KEY)).resolves.toBe(false);
|
||||
await expect(storage.open(KEY)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('들이는 동안 다시 센 SHA-256 · 크기가 검사 때와 다르면 들이지 않고 아무것도 남기지 않는다', async () => {
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sha256: sha256(Buffer.from('other')),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sizeBytes: data.length - 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
await expect(
|
||||
storage.importFile(source, KEY, {
|
||||
...expected(),
|
||||
sizeBytes: data.length + 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AppReleaseFileChangedError);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('쓰다 실패해도(원본을 읽지 못함) 반쯤 쓴 임시 파일을 남기지 않는다', async () => {
|
||||
const broken = {
|
||||
read: jest.fn().mockRejectedValue(new Error('EIO')),
|
||||
} as unknown as FileHandle;
|
||||
await expect(storage.importFile(broken, KEY, expected())).rejects.toThrow(
|
||||
'EIO',
|
||||
);
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('같은 키의 임시 파일이 있으면(다른 올리기가 쓰는 중) 덮어쓰지도 지우지도 않는다', async () => {
|
||||
await storage.importFile(source, OTHER_KEY, expected());
|
||||
writeFileSync(join(dir, `${KEY}.apk.part`), 'someone else');
|
||||
|
||||
await expect(storage.importFile(source, KEY, expected())).rejects.toThrow();
|
||||
expect(readFileSync(join(dir, `${KEY}.apk.part`), 'utf8')).toBe(
|
||||
'someone else',
|
||||
);
|
||||
});
|
||||
|
||||
it('서버가 만든 키(소문자 UUID)가 아니면 경로로 쓰지 않는다', async () => {
|
||||
for (const key of [
|
||||
'../../etc/passwd',
|
||||
`../${KEY}`,
|
||||
`${KEY}/x`,
|
||||
KEY.toUpperCase(),
|
||||
`${KEY}.apk`,
|
||||
'',
|
||||
]) {
|
||||
await expect(storage.importFile(source, key, expected())).rejects.toThrow(
|
||||
'휴대폰 앱 파일 키 형식이 올바르지 않습니다.',
|
||||
);
|
||||
await expect(storage.open(key)).rejects.toThrow();
|
||||
await expect(storage.exists(key)).rejects.toThrow();
|
||||
await expect(storage.remove(key)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('멈춘 올리기의 오래된 임시 파일만 지운다 — 새 임시 파일 · 판 파일 · 다른 이름은 그대로, 폴더가 없으면 0', async () => {
|
||||
await expect(storage.removeStaleParts(new Date())).resolves.toBe(0);
|
||||
|
||||
await storage.importFile(source, OTHER_KEY, expected());
|
||||
const old = join(dir, `${KEY}.apk.part`);
|
||||
const fresh = join(dir, '0f8fad5b-d9cb-469f-a165-70867728950e.apk.part');
|
||||
const stranger = join(dir, 'notes.part');
|
||||
for (const path of [old, fresh, stranger]) writeFileSync(path, 'x');
|
||||
const longAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
utimesSync(old, longAgo, longAgo);
|
||||
utimesSync(stranger, longAgo, longAgo);
|
||||
|
||||
await expect(
|
||||
storage.removeStaleParts(new Date(Date.now() - 60 * 60 * 1000)),
|
||||
).resolves.toBe(1);
|
||||
expect(readdirSync(dir).sort()).toEqual(
|
||||
[
|
||||
`${OTHER_KEY}.apk`,
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
|
||||
'notes.part',
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
type FileHandle,
|
||||
} from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { forEachChunk } from './apk-file';
|
||||
import { APK_EXTENSION, APP_RELEASE_DIR } from './app-releases.constants';
|
||||
|
||||
/** 서버가 만든 키(소문자 UUID)만 경로로 만든다 — 바깥 값이 경로가 되지 않게 (`../../etc/passwd` 같은 값) */
|
||||
const FILE_KEY =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** 쓰는 중인 임시 파일의 꼬리 — `<key>.apk.part` */
|
||||
const PART_SUFFIX = '.part';
|
||||
/** 임시 파일 이름 — 정리할 때 이 꼴만 지운다 */
|
||||
const PART_FILE_NAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.apk\.part$/;
|
||||
|
||||
/** 파일 권한 — 소유자만 쓴다 (그룹은 읽기만) — 서명 그림 · 대용량 첨부 저장과 같다 */
|
||||
const FILE_MODE = 0o640;
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function unlinkIfExists(path: string): Promise<void> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 버퍼를 끝까지 쓴다 (한 번에 다 쓰지 못할 수 있다) */
|
||||
async function writeAll(
|
||||
out: FileHandle,
|
||||
data: Buffer,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
let done = 0;
|
||||
while (done < data.length) {
|
||||
const { bytesWritten } = await out.write(
|
||||
data,
|
||||
done,
|
||||
data.length - done,
|
||||
position + done,
|
||||
);
|
||||
done += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장소에 들이는 동안 원본이 검사한 것과 달라졌다 (SHA-256 · 크기) — 들이지 않았다 */
|
||||
export class AppReleaseFileChangedError extends Error {
|
||||
override readonly name = 'AppReleaseFileChangedError';
|
||||
|
||||
constructor() {
|
||||
super('APK 가 검사한 뒤 바뀌었습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/** 연 판 파일 — 크기는 열린 파일에서(fstat). 부르는 쪽이 닫거나 읽기 흐름에 넘긴다 */
|
||||
export interface OpenedAppReleaseFile {
|
||||
readonly handle: FileHandle;
|
||||
readonly sizeBytes: number;
|
||||
}
|
||||
|
||||
/** 들이는 파일이 맞아야 할 값 — 검사 때 센 것 */
|
||||
export interface ExpectedAppReleaseFile {
|
||||
readonly sizeBytes: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 파일 저장소 — 업로드 볼륨의 하위 폴더(APP_RELEASE_DIR) 안 `<file_key>.apk`.
|
||||
* 폴더 그대로 정적 서빙하지 않는다 — 내려줄 때는 서비스가 형식 · 머리글을 정한다 (.claude/rules/security.md "파일 업로드").
|
||||
* 모듈은 useFactory 로 만든다(생성자의 폴더 인자를 DI 가 찾지 않게) — 테스트는 임시 폴더를 넘긴다
|
||||
*/
|
||||
export class AppReleaseStorage {
|
||||
constructor(private readonly dir: string = APP_RELEASE_DIR) {}
|
||||
|
||||
/** 판 파일을 연다 — 없으면 null */
|
||||
async open(fileKey: string): Promise<OpenedAppReleaseFile | null> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await open(this.pathOf(fileKey), 'r');
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return { handle, sizeBytes: (await handle.stat()).size };
|
||||
} catch (error) {
|
||||
await handle.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 판 파일이 있는가 (운영 도구의 목록) */
|
||||
async exists(fileKey: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(this.pathOf(fileKey));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영자가 넣은 APK(검사하며 연 파일)를 들인다 — `<key>.apk.part` 에 쓰고(wx — 덮어쓰지 않는다) 디스크에 내려쓴 뒤 `<key>.apk` 로 이름을 바꾼다.
|
||||
* 쓰면서 다시 센 SHA-256 · 크기가 검사 때와 다르면(검사한 뒤 원본이 바뀜) AppReleaseFileChangedError.
|
||||
* 어떻게 실패해도 반쯤 쓴 임시 파일을 남기지 않는다 — 이름을 바꾸기 전에는 `<key>.apk` 가 생기지 않는다
|
||||
*/
|
||||
async importFile(
|
||||
source: FileHandle,
|
||||
fileKey: string,
|
||||
expected: ExpectedAppReleaseFile,
|
||||
): Promise<void> {
|
||||
const finalPath = this.pathOf(fileKey);
|
||||
const partPath = `${finalPath}${PART_SUFFIX}`;
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
const out = await open(partPath, 'wx', FILE_MODE);
|
||||
let closed = false;
|
||||
let renamed = false;
|
||||
try {
|
||||
const hash = createHash('sha256');
|
||||
let written = 0;
|
||||
const complete = await forEachChunk(
|
||||
source,
|
||||
expected.sizeBytes,
|
||||
async (chunk) => {
|
||||
hash.update(chunk);
|
||||
await writeAll(out, chunk, written);
|
||||
written += chunk.length;
|
||||
},
|
||||
);
|
||||
if (!complete || hash.digest('hex') !== expected.sha256) {
|
||||
throw new AppReleaseFileChangedError();
|
||||
}
|
||||
// 이름을 바꾸기 전에 디스크에 내려쓴다 — 표에 넣은 판의 파일이 전원이 꺼져도 남게
|
||||
await out.datasync();
|
||||
closed = true;
|
||||
await out.close();
|
||||
await rename(partPath, finalPath);
|
||||
renamed = true;
|
||||
} finally {
|
||||
if (!closed) await out.close().catch(() => undefined);
|
||||
if (!renamed) await unlinkIfExists(partPath);
|
||||
}
|
||||
}
|
||||
|
||||
/** 판 파일을 지운다 — 이미 없으면 그대로 (표에 넣지 못했을 때 되돌리기) */
|
||||
async remove(fileKey: string): Promise<void> {
|
||||
await unlinkIfExists(this.pathOf(fileKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 멈춘 올리기(도구가 쓰는 중에 끊김)가 남긴 임시 파일 가운데 `olderThan` 보다 오래된 것을 지운다 → 지운 수.
|
||||
* 폴더가 없으면 0. 그사이 지워진 것은 건너뛴다
|
||||
*/
|
||||
async removeStaleParts(olderThan: Date): Promise<number> {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(this.dir);
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return 0;
|
||||
throw error;
|
||||
}
|
||||
let removed = 0;
|
||||
for (const name of names.filter((entry) => PART_FILE_NAME.test(entry))) {
|
||||
const path = join(this.dir, name);
|
||||
try {
|
||||
if ((await stat(path)).mtime >= olderThan) continue;
|
||||
await unlink(path);
|
||||
removed += 1;
|
||||
} catch (error) {
|
||||
if (!isMissing(error)) throw error;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private pathOf(fileKey: string): string {
|
||||
if (!FILE_KEY.test(fileKey)) {
|
||||
// 서버가 만든 키가 아니다 — 버그이므로 응답 코드 없는 내부 오류로 둔다 (SYS_001)
|
||||
throw new Error('휴대폰 앱 파일 키 형식이 올바르지 않습니다.');
|
||||
}
|
||||
return join(this.dir, `${fileKey}${APK_EXTENSION}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import {
|
||||
appReleaseFileName,
|
||||
compareAppVersions,
|
||||
isAppVersion,
|
||||
versionInFileName,
|
||||
} from './app-release-version';
|
||||
|
||||
describe('휴대폰 앱 버전 (사내 배포)', () => {
|
||||
it('버전 글 — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이), 앱 알림 등록의 appVersion 과 같은 규칙', () => {
|
||||
for (const version of ['1.0.0', '0.0.1', '10.20.30', '1234.0.9999']) {
|
||||
expect(isAppVersion(version)).toBe(true);
|
||||
}
|
||||
for (const version of [
|
||||
'',
|
||||
'1.0',
|
||||
'1.0.0.1',
|
||||
'01.0.0',
|
||||
'1.00.0',
|
||||
'1.0.0-beta',
|
||||
'1.0.0+5',
|
||||
'v1.0.0',
|
||||
'12345.0.0',
|
||||
' 1.0.0',
|
||||
'1.0.0\n',
|
||||
]) {
|
||||
expect(isAppVersion(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('칸마다 숫자로 견준다 — 글자 차례가 아니다(1.10.0 > 1.9.0)', () => {
|
||||
expect(compareAppVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
|
||||
expect(compareAppVersions('1.9.9', '2.0.0')).toBeLessThan(0);
|
||||
expect(compareAppVersions('1.2.3', '1.2.3')).toBe(0);
|
||||
expect(compareAppVersions('0.9.0', '1.0.0')).toBeLessThan(0);
|
||||
expect(compareAppVersions('1.0.10', '1.0.2')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('파일 이름에서 버전을 찾는다 — 없거나 규칙에 맞지 않으면 null', () => {
|
||||
expect(versionInFileName('ARA-SYSTEM-1.2.3.apk')).toBe('1.2.3');
|
||||
expect(versionInFileName('ARA-SYSTEM-v10.0.1.APK')).toBe('10.0.1');
|
||||
expect(versionInFileName('app-release-1.0.0+12.apk')).toBe('1.0.0');
|
||||
expect(versionInFileName('app-release.apk')).toBeNull();
|
||||
// 네 칸 · 다섯 자리 · 앞자리 0 — 판으로 보지 않는다
|
||||
expect(versionInFileName('ARA-SYSTEM-1.2.3.4.apk')).toBeNull();
|
||||
expect(versionInFileName('ARA-SYSTEM-12345.0.0.apk')).toBeNull();
|
||||
expect(versionInFileName('ARA-SYSTEM-01.0.0.apk')).toBeNull();
|
||||
});
|
||||
|
||||
it('파일 이름에서 찾기는 긴 이름에도 길이에 비례한다 (숫자 · 점이 잔뜩 든 이름)', () => {
|
||||
const name = `${'1.'.repeat(100_000)}apk`;
|
||||
const cpuMs = startCpuTimer();
|
||||
expect(versionInFileName(name)).toBeNull();
|
||||
// 지금 수 ms — 한도는 넉넉히(부하에서 는다 · .claude/rules/testing.md)
|
||||
expect(cpuMs()).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it('내려받는 파일 이름 — ARA-SYSTEM-<버전>.apk', () => {
|
||||
expect(appReleaseFileName('1.0.0')).toBe('ARA-SYSTEM-1.0.0.apk');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
APP_VERSION_MAX_LENGTH,
|
||||
APP_VERSION_PATTERN,
|
||||
} from '@/modules/push/push.constants';
|
||||
import { APK_EXTENSION } from './app-releases.constants';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 판 (순수 함수) — 판 글은 앱 알림 등록의 `appVersion` 과 **같은 규칙**이다(push.constants 의 APP_VERSION_PATTERN —
|
||||
* `숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 앱 설정의 최소 판(app-config)도 같은 꼴로 칸마다 숫자로 견준다
|
||||
*/
|
||||
|
||||
/** 파일 이름 안의 판 후보 — 앞뒤가 숫자 · 점으로 이어지지 않는 `x.y.z` (`1.2.3.4` 의 일부는 판으로 보지 않는다). 칸마다 4자리까지라 길이에 비례한다 */
|
||||
const VERSION_IN_NAME =
|
||||
/(?<![0-9.])([0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4})(?![0-9]|\.[0-9])/;
|
||||
|
||||
/** 판 글인가 — 길이를 먼저 본다(정규식 전에) */
|
||||
export function isAppVersion(text: string): boolean {
|
||||
return (
|
||||
text.length <= APP_VERSION_MAX_LENGTH && APP_VERSION_PATTERN.test(text)
|
||||
);
|
||||
}
|
||||
|
||||
/** 두 판을 칸마다 숫자로 견준다 — a 가 낮으면 음수, 같으면 0, 높으면 양수 (둘 다 판 글이어야 한다) */
|
||||
export function compareAppVersions(a: string, b: string): number {
|
||||
const left = a.split('.').map(Number);
|
||||
const right = b.split('.').map(Number);
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const difference = left[index] - right[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 운영자가 넣은 파일 이름에서 판을 찾는다 (`ARA-SYSTEM-1.2.3.apk` → `1.2.3`) — 없거나 판 규칙에 맞지 않으면 null */
|
||||
export function versionInFileName(fileName: string): string | null {
|
||||
const match = VERSION_IN_NAME.exec(fileName);
|
||||
return match && isAppVersion(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
/** 직원이 내려받는 파일 이름 — 서버가 검사한 판으로만 만든다 (`ARA-SYSTEM-1.0.0.apk`) */
|
||||
export function appReleaseFileName(version: string): string {
|
||||
return `ARA-SYSTEM-${version}${APK_EXTENSION}`;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import { BIGINT_NUMBER } from '@/shared/utils/bigint-column';
|
||||
import {
|
||||
APP_RELEASES_TABLE,
|
||||
APP_RELEASE_UPLOADER_MAX_LENGTH,
|
||||
type AppReleasePlatform,
|
||||
} from './app-releases.constants';
|
||||
|
||||
/** 플랫폼 이름 길이 — 'android' (push_registrations.platform 과 같다) */
|
||||
const PLATFORM_COLUMN_LENGTH = 16;
|
||||
/** 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push_registrations.app_version 과 같다) */
|
||||
const VERSION_COLUMN_LENGTH = 16;
|
||||
/** SHA-256 — 소문자 16진수 64자 */
|
||||
const SHA256_HEX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* 나눠 주는 휴대폰 앱 한 판 (계약 1절 "휴대폰 앱 받기" — 사내 배포) — 운영 도구 「휴대폰 앱 올리기」가 한 줄씩 넣는다.
|
||||
*
|
||||
* - "최신" = 그 플랫폼에서 가장 늦게 올린 줄(created_at) — 판 번호가 아니다. 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길)
|
||||
* - 파일은 업로드 볼륨의 `app-releases/<file_key>.apk` — 디스크 이름은 서버가 만든 UUID 뿐이다(운영자가 넣은 파일 이름 · 경로를 쓰지 않는다)
|
||||
* - 지우는 명령은 없다 — 옛 판은 표 · 볼륨에 남는다(되돌리기 · 문의 대응). 직원 주소로 키링한 표가 아니라 퇴사 마무리와 상관없다
|
||||
*/
|
||||
@Entity({ name: APP_RELEASES_TABLE })
|
||||
@Index('IDX_app_releases_platform_created_at', ['platform', 'createdAt'])
|
||||
export class AppReleaseEntity {
|
||||
/** 판 id — 서버가 만든 UUID (내려받기 응답의 ETag) */
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
/** 플랫폼 — 지금은 android 만 */
|
||||
@Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH })
|
||||
platform!: AppReleasePlatform;
|
||||
|
||||
/** 판 (`1.0.0` — 앱의 versionName. 앱 알림 등록의 appVersion 과 같은 규칙) */
|
||||
@Column({ type: 'varchar', length: VERSION_COLUMN_LENGTH })
|
||||
version!: string;
|
||||
|
||||
/** 파일 크기(바이트) — 내려받기의 Content-Length · 범위 계산이 이 값을 쓴다(디스크의 크기와 다르면 내려주지 않는다) */
|
||||
@Column({ name: 'size_bytes', type: 'bigint', transformer: BIGINT_NUMBER })
|
||||
sizeBytes!: number;
|
||||
|
||||
/** 파일의 SHA-256 (소문자 16진수) — 운영자가 빌드한 파일과 견준다 */
|
||||
@Column({ type: 'char', length: SHA256_HEX_LENGTH })
|
||||
sha256!: string;
|
||||
|
||||
/** 디스크의 파일 이름(확장자 빼고) — 서버가 만든 UUID */
|
||||
@Index('UQ_app_releases_file_key', { unique: true })
|
||||
@Column({ name: 'file_key', type: 'uuid' })
|
||||
fileKey!: string;
|
||||
|
||||
/** 올린 이 — 운영 도구를 시작할 때 넣은 운영자 이름 */
|
||||
@Column({
|
||||
name: 'uploaded_by',
|
||||
type: 'varchar',
|
||||
length: APP_RELEASE_UPLOADER_MAX_LENGTH,
|
||||
})
|
||||
uploadedBy!: string;
|
||||
|
||||
/** 올린 때 — 최신을 가르는 차례 · 화면의 "올린 날"(`releasedAt`) */
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 한도 · 형식 · 자리를 한 곳에 모은다.
|
||||
* 앱은 스토어 없이 직접 빌드한 APK 를 포털에 로그인한 직원에게만 나눠 준다.
|
||||
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts)
|
||||
*/
|
||||
|
||||
/** 표 이름 — app-release.entity.ts 와 같다 */
|
||||
export const APP_RELEASES_TABLE = 'app_releases';
|
||||
|
||||
/** 나눠 주는 플랫폼 — 지금은 안드로이드만 (아이폰은 나중 거리) */
|
||||
export const APP_RELEASE_PLATFORMS = ['android'] as const;
|
||||
export type AppReleasePlatform = (typeof APP_RELEASE_PLATFORMS)[number];
|
||||
|
||||
/** APK 의 형식 — 내려받기 응답의 Content-Type (휴대폰이 설치 파일로 알아본다) */
|
||||
export const APK_CONTENT_TYPE = 'application/vnd.android.package-archive';
|
||||
|
||||
/** 확장자 — 운영자가 넣은 파일 이름에서 본다(대소문자는 가리지 않는다) · 내려받는 파일 이름에 붙인다 */
|
||||
export const APK_EXTENSION = '.apk';
|
||||
|
||||
/** zip 의 로컬 파일 머리 `PK\x03\x04` — APK 는 zip 이다 */
|
||||
export const APK_ZIP_SIGNATURE: Readonly<Buffer> = Buffer.from([
|
||||
0x50, 0x4b, 0x03, 0x04,
|
||||
]);
|
||||
|
||||
/** 크기 한도 — 1KB 아래는 APK 가 아니다, 200MB 위는 받지 않는다(지금 앱은 30MB 남짓) */
|
||||
export const APK_MIN_BYTES = 1024;
|
||||
export const APK_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 파일 자리 — 업로드 볼륨(modules/upload 와 같은 UPLOAD_DIR)의 하위 폴더. 이름은 `<file_key>.apk` 뿐이다.
|
||||
* 운영은 named volume `<PROJECT_NAME>_uploads`(매일 백업에 든다), 개발은 바인드 마운트 (.claude/skills/docker/SKILL.md)
|
||||
*/
|
||||
export const APP_RELEASE_DIR = join(UPLOAD_DIR, 'app-releases');
|
||||
|
||||
/** 운영 도구 「휴대폰 앱 목록」이 보이는 판 수 — 최근 것부터 */
|
||||
export const APP_RELEASE_LIST_LIMIT = 10;
|
||||
|
||||
/** 올린 이(운영자 이름) 길이 — 운영 도구의 운영자 이름 한도(ops-audit-log.entity.ts 의 OPS_OPERATOR_MAX_LENGTH)와 같다 */
|
||||
export const APP_RELEASE_UPLOADER_MAX_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* 멈춘 올리기가 남긴 임시 파일(`*.apk.part`)을 지우는 나이 — 올리기를 시작할 때 이보다 오래된 것만 지운다.
|
||||
* 다른 운영자가 막 쓰는 중인 임시 파일은 건드리지 않게 넉넉히 둔다(200MB 를 쓰는 데 몇 초다)
|
||||
*/
|
||||
export const APP_RELEASE_STALE_PART_MS = 60 * 60 * 1000;
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCookieAuth,
|
||||
ApiOperation,
|
||||
ApiProduces,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiSessionErrors,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { APK_CONTENT_TYPE } from './app-releases.constants';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
import { AppReleaseDto } from './dto/app-release.dto';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기", 2026-11-08) — 포털에 로그인한 직원만 받는다(웹 쿠키 · 앱 Bearer — JwtAuthGuard).
|
||||
* 제한된 세션(새 비밀번호 · 2단계 켜기 전)은 403 AUTH_010 — `@AllowRestrictedSession()` 을 달지 않는다.
|
||||
* 올리기는 API 가 없다 — 서버 B 의 운영 도구 「휴대폰 앱 올리기」(modules/ops/app-release-commands.ts).
|
||||
*
|
||||
* "올린 판이 없음"(404)은 화면이 설정을 열 때마다 묻는 예상된 상태라 debug 로만 남긴다 — 표에는 있는데 파일이 없는 404 는 서비스가 error 로 따로 남긴다
|
||||
*/
|
||||
@ApiTags('App')
|
||||
@ApiCookieAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiSessionErrors()
|
||||
@UseFilters(new HttpExceptionFilter({ quietStatuses: [HttpStatus.NOT_FOUND] }))
|
||||
@Controller('app-releases')
|
||||
export class AppReleasesController {
|
||||
constructor(private readonly releases: AppReleasesService) {}
|
||||
|
||||
@Get('android/latest')
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '안드로이드 앱의 최신 판 정보',
|
||||
description:
|
||||
'최신 = 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다. ' +
|
||||
'올린 판이 없으면 404 RES_001 — 화면은 "아직 받을 수 있는 앱이 없습니다" 칸을 보인다(알림 끔). Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(AppReleaseDto, { description: '최신 판 정보' })
|
||||
@ApiErrorResponse('RES_001', '올린 판이 없다')
|
||||
latest(): Promise<AppReleaseDto> {
|
||||
return this.releases.latestInfo('android');
|
||||
}
|
||||
|
||||
@Get('android/latest/file')
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'안드로이드 앱의 최신 판 파일 (GET · HEAD · Range — 표준 래퍼 없음)',
|
||||
description:
|
||||
`늘 ${APK_CONTENT_TYPE} + Content-Disposition: attachment; filename="ARA-SYSTEM-<버전>.apk" + nosniff + sandbox CSP + private, no-store. ` +
|
||||
'강한 ETag("<판 id>") · Last-Modified(올린 때) · Accept-Ranges: bytes — 대용량 첨부 받기와 같은 머리글이고 형식만 APK 다. ' +
|
||||
'Range 하나면 206, 여럿 · 형식 오류면 200 전체, 처음이 크기 이상이면 416(본문 없음). HEAD 는 머리글만. ' +
|
||||
'올린 판이 없거나 그 파일이 없으면 404 RES_001. 화면은 첨부 내려받기처럼 이동 전에 세션을 확인한다.',
|
||||
})
|
||||
@ApiProduces(APK_CONTENT_TYPE)
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: 'APK 파일 (206 — 범위)',
|
||||
schema: { type: 'string', format: 'binary' },
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
||||
description: '받을 수 없는 범위 — Content-Range: bytes */<크기>, 본문 없음',
|
||||
})
|
||||
@ApiErrorResponse('RES_001', '올린 판이 없다 · 그 파일이 없다')
|
||||
async file(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
await this.releases.serveLatestFile(req, res, 'android');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppReleaseStorage } from './app-release-storage';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import { AppReleasesController } from './app-releases.controller';
|
||||
import { AppReleasesRepository } from './app-releases.repository';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 모듈 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 최신 판 정보 · 파일(`/api/app-releases/android/latest` · `/file`).
|
||||
* 판은 표 app_releases, 파일은 업로드 볼륨의 `app-releases/`. 올리기는 운영 도구가 같은 서비스 · 저장소를 다시 엮어 쓴다(modules/ops/ops.module.ts).
|
||||
* 이 모듈은 다른 기능 모듈을 가져오지 않는다
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AppReleaseEntity])],
|
||||
controllers: [AppReleasesController],
|
||||
providers: [
|
||||
AppReleasesRepository,
|
||||
{
|
||||
provide: AppReleaseStorage,
|
||||
useFactory: () => new AppReleaseStorage(),
|
||||
},
|
||||
AppReleasesService,
|
||||
],
|
||||
exports: [AppReleasesService],
|
||||
})
|
||||
export class AppReleasesModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import type { AppReleasePlatform } from './app-releases.constants';
|
||||
|
||||
/** 늦게 올린 것부터 — 같은 시각이면 id 로 차례를 정한다(늘 같은 답) */
|
||||
const NEWEST_FIRST = { createdAt: 'DESC', id: 'DESC' } as const;
|
||||
|
||||
/** 휴대폰 앱 판 표 (app_releases) */
|
||||
@Injectable()
|
||||
export class AppReleasesRepository {
|
||||
constructor(
|
||||
@InjectRepository(AppReleaseEntity)
|
||||
private readonly rows: Repository<AppReleaseEntity>,
|
||||
) {}
|
||||
|
||||
/** 그 플랫폼에서 가장 늦게 올린 판 — 없으면 null */
|
||||
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
|
||||
return this.rows.findOne({ where: { platform }, order: NEWEST_FIRST });
|
||||
}
|
||||
|
||||
/** 늦게 올린 것부터 `limit` 개 */
|
||||
recent(
|
||||
platform: AppReleasePlatform,
|
||||
limit: number,
|
||||
): Promise<AppReleaseEntity[]> {
|
||||
return this.rows.find({
|
||||
where: { platform },
|
||||
order: NEWEST_FIRST,
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async insert(release: AppReleaseEntity): Promise<void> {
|
||||
await this.rows.insert(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { inspectApk, type InspectedApk } from './apk-file';
|
||||
import { AppReleaseStorage } from './app-release-storage';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import { APK_ZIP_SIGNATURE } from './app-releases.constants';
|
||||
import type { AppReleasesRepository } from './app-releases.repository';
|
||||
import { AppReleasesService } from './app-releases.service';
|
||||
|
||||
/** 표 대역 — 늦게 올린 것부터 (실제 차례는 e2e 가 PostgreSQL 로 본다) */
|
||||
class FakeRepository {
|
||||
readonly rows: AppReleaseEntity[] = [];
|
||||
insertFailure: Error | null = null;
|
||||
|
||||
private newestFirst(platform: string): AppReleaseEntity[] {
|
||||
return this.rows
|
||||
.filter((row) => row.platform === platform)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
latest(platform: string) {
|
||||
return Promise.resolve(this.newestFirst(platform)[0] ?? null);
|
||||
}
|
||||
|
||||
recent(platform: string, limit: number) {
|
||||
return Promise.resolve(this.newestFirst(platform).slice(0, limit));
|
||||
}
|
||||
|
||||
insert(release: AppReleaseEntity) {
|
||||
if (this.insertFailure) return Promise.reject(this.insertFailure);
|
||||
this.rows.push(release);
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/** 응답 대역 — 흘려보낸 바이트 · 상태 · 머리글을 모은다 */
|
||||
class FakeResponse extends PassThrough {
|
||||
statusCode = 200;
|
||||
readonly headers = new Map<string, string>();
|
||||
readonly received: Buffer[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.on('data', (part: Buffer) => this.received.push(part));
|
||||
}
|
||||
|
||||
status(code: number): this {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
setHeader(name: string, value: string): this {
|
||||
this.headers.set(name.toLowerCase(), value);
|
||||
return this;
|
||||
}
|
||||
|
||||
get body(): Buffer {
|
||||
return Buffer.concat(this.received);
|
||||
}
|
||||
}
|
||||
|
||||
const request = (method: string, headers: Record<string, string> = {}) =>
|
||||
({ method, headers }) as unknown as Request;
|
||||
|
||||
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
|
||||
try {
|
||||
await run();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
}
|
||||
|
||||
describe('AppReleasesService — 휴대폰 앱 받기 (사내 배포)', () => {
|
||||
let root: string;
|
||||
let releaseDir: string;
|
||||
let repository: FakeRepository;
|
||||
let service: AppReleasesService;
|
||||
let errors: string[];
|
||||
const content = Buffer.concat([APK_ZIP_SIGNATURE, randomBytes(4096)]);
|
||||
|
||||
/** 운영자가 넣은 것처럼 — 임시 폴더의 APK 를 검사해 연 파일째 */
|
||||
async function inspected(data: Buffer = content): Promise<InspectedApk> {
|
||||
const path = join(root, `in-${randomBytes(4).toString('hex')}.apk`);
|
||||
writeFileSync(path, data);
|
||||
const inspection = await inspectApk(path);
|
||||
if (!inspection.ok) throw new Error(inspection.problem);
|
||||
return inspection.apk;
|
||||
}
|
||||
|
||||
async function publish(
|
||||
version: string,
|
||||
at: Date,
|
||||
data: Buffer = content,
|
||||
): Promise<AppReleaseEntity> {
|
||||
const apk = await inspected(data);
|
||||
try {
|
||||
return await service.publish(
|
||||
apk,
|
||||
{ platform: 'android', version, uploadedBy: '운영자 김' },
|
||||
at,
|
||||
);
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function serve(req: Request): Promise<FakeResponse> {
|
||||
const res = new FakeResponse();
|
||||
await service.serveLatestFile(req, res as unknown as Response, 'android');
|
||||
return res;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'app-releases-'));
|
||||
releaseDir = join(root, 'app-releases');
|
||||
repository = new FakeRepository();
|
||||
service = new AppReleasesService(
|
||||
repository as unknown as AppReleasesRepository,
|
||||
new AppReleaseStorage(releaseDir),
|
||||
);
|
||||
errors = [];
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('최신 판 정보 — 올린 판이 없으면 404 RES_001, 있으면 가장 늦게 올린 판(버전 번호가 아니다)', async () => {
|
||||
expect(await codeOf(() => service.latestInfo('android'))).toBe('RES_001');
|
||||
|
||||
await publish('1.2.0', new Date('2026-11-18T01:00:00.000Z'));
|
||||
const older = await publish('1.1.0', new Date('2026-11-19T01:23:45.000Z'));
|
||||
await expect(service.latestInfo('android')).resolves.toEqual({
|
||||
version: '1.1.0',
|
||||
sizeBytes: content.length,
|
||||
releasedAt: older.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('올리기 — 볼륨에 <file_key>.apk 하나 · 표에 한 줄(크기 · SHA-256 · 올린 이)', async () => {
|
||||
const release = await publish(
|
||||
'1.0.0',
|
||||
new Date('2026-11-18T01:23:45.000Z'),
|
||||
);
|
||||
expect(repository.rows).toEqual([release]);
|
||||
expect(release).toMatchObject({
|
||||
platform: 'android',
|
||||
version: '1.0.0',
|
||||
sizeBytes: content.length,
|
||||
uploadedBy: '운영자 김',
|
||||
});
|
||||
expect(release.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(release.fileKey).not.toBe(release.id);
|
||||
expect(readdirSync(releaseDir)).toEqual([`${release.fileKey}.apk`]);
|
||||
});
|
||||
|
||||
it('올리기 — 표에 넣지 못하면 들인 파일을 지우고 던진다(반쯤 올린 판을 남기지 않는다)', async () => {
|
||||
repository.insertFailure = new Error('db down');
|
||||
const apk = await inspected();
|
||||
try {
|
||||
await expect(
|
||||
service.publish(apk, {
|
||||
platform: 'android',
|
||||
version: '1.0.0',
|
||||
uploadedBy: '운영자 김',
|
||||
}),
|
||||
).rejects.toThrow('db down');
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
expect(repository.rows).toEqual([]);
|
||||
expect(readdirSync(releaseDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it('올리기 — 버전 글이 아니면 아무것도 하지 않고 던진다(부르는 쪽이 먼저 본다)', async () => {
|
||||
const apk = await inspected();
|
||||
try {
|
||||
await expect(
|
||||
service.publish(apk, {
|
||||
platform: 'android',
|
||||
version: '1.0',
|
||||
uploadedBy: '운영자 김',
|
||||
}),
|
||||
).rejects.toThrow('휴대폰 앱 버전 형식이 올바르지 않습니다.');
|
||||
} finally {
|
||||
await apk.close();
|
||||
}
|
||||
expect(existsSync(releaseDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('올리기 — 멈춘 올리기가 남긴 한 시간 넘은 임시 파일을 먼저 지운다', async () => {
|
||||
const warn = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
await publish('1.0.0', new Date('2026-11-18T01:00:00.000Z'));
|
||||
const stale = join(
|
||||
releaseDir,
|
||||
'0f8fad5b-d9cb-469f-a165-70867728950e.apk.part',
|
||||
);
|
||||
writeFileSync(stale, 'half');
|
||||
|
||||
// 지금 시각(파일의 고친 때)보다 두 시간 뒤에 올린다
|
||||
await publish('1.0.1', new Date(Date.now() + 2 * 60 * 60 * 1000));
|
||||
expect(existsSync(stale)).toBe(false);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('임시 파일 1개를 지웠습니다'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('파일 응답 (Range · HEAD)', () => {
|
||||
let release: AppReleaseEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
release = await publish('1.0.0', new Date('2026-11-18T01:23:45.000Z'));
|
||||
});
|
||||
|
||||
it('전체 200 — APK 형식 · 이름 · 대용량 받기와 같은 머리글(nosniff · sandbox · no-store · ETag · Accept-Ranges · X-Accel-Buffering)', async () => {
|
||||
const res = await serve(request('GET'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.equals(content)).toBe(true);
|
||||
expect(Object.fromEntries(res.headers)).toEqual({
|
||||
'content-type': 'application/vnd.android.package-archive',
|
||||
'content-disposition': 'attachment; filename="ARA-SYSTEM-1.0.0.apk"',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-security-policy': "default-src 'none'; sandbox",
|
||||
'cache-control': 'private, no-store',
|
||||
'referrer-policy': 'no-referrer',
|
||||
'x-robots-tag': 'noindex',
|
||||
'accept-ranges': 'bytes',
|
||||
etag: `"${release.id}"`,
|
||||
'last-modified': release.createdAt.toUTCString(),
|
||||
'x-accel-buffering': 'no',
|
||||
'content-length': String(content.length),
|
||||
});
|
||||
});
|
||||
|
||||
it('범위 하나 206 · 끝 N 바이트 · 처음이 크기 이상이면 416(본문 없음) · If-Range 가 다르면 전체 200 · HEAD 는 머리글만', async () => {
|
||||
const partial = await serve(request('GET', { range: 'bytes=0-99' }));
|
||||
expect(partial.statusCode).toBe(206);
|
||||
expect(partial.body.equals(content.subarray(0, 100))).toBe(true);
|
||||
expect(partial.headers.get('content-range')).toBe(
|
||||
`bytes 0-99/${content.length}`,
|
||||
);
|
||||
expect(partial.headers.get('content-length')).toBe('100');
|
||||
|
||||
const suffix = await serve(request('GET', { range: 'bytes=-10' }));
|
||||
expect(suffix.statusCode).toBe(206);
|
||||
expect(suffix.body.equals(content.subarray(content.length - 10))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const unsatisfiable = await serve(
|
||||
request('GET', { range: `bytes=${content.length}-` }),
|
||||
);
|
||||
expect(unsatisfiable.statusCode).toBe(416);
|
||||
expect(unsatisfiable.headers.get('content-range')).toBe(
|
||||
`bytes */${content.length}`,
|
||||
);
|
||||
expect(unsatisfiable.headers.get('content-length')).toBe('0');
|
||||
expect(unsatisfiable.body.length).toBe(0);
|
||||
|
||||
const changed = await serve(
|
||||
request('GET', { range: 'bytes=0-9', 'if-range': '"other"' }),
|
||||
);
|
||||
expect(changed.statusCode).toBe(200);
|
||||
expect(changed.body.length).toBe(content.length);
|
||||
const resumed = await serve(
|
||||
request('GET', { range: 'bytes=10-', 'if-range': `"${release.id}"` }),
|
||||
);
|
||||
expect(resumed.statusCode).toBe(206);
|
||||
expect(resumed.body.equals(content.subarray(10))).toBe(true);
|
||||
|
||||
const head = await serve(request('HEAD', { range: 'bytes=0-1' }));
|
||||
expect(head.statusCode).toBe(206);
|
||||
expect(head.headers.get('content-length')).toBe('2');
|
||||
expect(head.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('표에는 있는데 파일이 없거나 크기가 다르면 404 RES_001 — 머리글을 쓰지 않고, 운영자가 알게 error 로그', async () => {
|
||||
writeFileSync(join(releaseDir, `${release.fileKey}.apk`), 'cut');
|
||||
const res = new FakeResponse();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.serveLatestFile(
|
||||
request('GET'),
|
||||
res as unknown as Response,
|
||||
'android',
|
||||
),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
expect(res.headers.size).toBe(0);
|
||||
expect(errors.join('\n')).toContain('크기가 표와 다름');
|
||||
|
||||
rmSync(join(releaseDir, `${release.fileKey}.apk`));
|
||||
expect(await codeOf(() => serve(request('GET')))).toBe('RES_001');
|
||||
expect(errors.join('\n')).toContain('파일 없음');
|
||||
expect(errors.join('\n')).toContain(`release=${release.id}`);
|
||||
});
|
||||
|
||||
it('올린 판이 없으면 404 RES_001 (머리글을 쓰기 전에)', async () => {
|
||||
repository.rows.length = 0;
|
||||
const res = new FakeResponse();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
service.serveLatestFile(
|
||||
request('GET'),
|
||||
res as unknown as Response,
|
||||
'android',
|
||||
),
|
||||
),
|
||||
).toBe('RES_001');
|
||||
expect(res.headers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('서버 종료가 시작되면 새 받기는 시작하지 않는다(연결을 끊는다 — 받는 쪽이 이어 받는다)', async () => {
|
||||
service.beforeApplicationShutdown();
|
||||
const res = await serve(request('GET'));
|
||||
expect(res.destroyed).toBe(true);
|
||||
expect(res.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it('목록 — 늦게 올린 것부터, 디스크에 파일이 있는지 함께', async () => {
|
||||
const newer = await publish(
|
||||
'1.0.1',
|
||||
new Date('2026-11-19T01:00:00.000Z'),
|
||||
);
|
||||
rmSync(join(releaseDir, `${release.fileKey}.apk`));
|
||||
await expect(service.listRecent('android', 10)).resolves.toEqual([
|
||||
{ release: newer, hasFile: true },
|
||||
{ release, hasFile: false },
|
||||
]);
|
||||
await expect(service.listRecent('android', 1)).resolves.toEqual([
|
||||
{ release: newer, hasFile: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
type BeforeApplicationShutdown,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { currentRequestId, traceTag } from '@/shared/logger/request-context';
|
||||
import { downloadHeaders } from '@/shared/utils/download-headers';
|
||||
import { decideRange } from '@/shared/utils/http-range';
|
||||
import { isStreamAbortError } from '@/shared/utils/stream-abort';
|
||||
import { inspectApk, type ApkInspection, type InspectedApk } from './apk-file';
|
||||
import {
|
||||
AppReleaseStorage,
|
||||
type OpenedAppReleaseFile,
|
||||
} from './app-release-storage';
|
||||
import { appReleaseFileName, isAppVersion } from './app-release-version';
|
||||
import { AppReleaseEntity } from './app-release.entity';
|
||||
import {
|
||||
APK_CONTENT_TYPE,
|
||||
APP_RELEASE_STALE_PART_MS,
|
||||
type AppReleasePlatform,
|
||||
} from './app-releases.constants';
|
||||
import { AppReleasesRepository } from './app-releases.repository';
|
||||
import type {
|
||||
AppReleaseInfo,
|
||||
AppReleaseListing,
|
||||
AppReleasePublish,
|
||||
} from './app-releases.types';
|
||||
|
||||
/** 버전 글이 아닌 줄(표를 손으로 고침)의 내려받는 이름 — 따옴표 안에 바깥 글이 들어가지 않게 */
|
||||
const FALLBACK_FILE_NAME = 'ARA-SYSTEM.apk';
|
||||
|
||||
/** 머리글 하나 (여럿이면 첫 값) */
|
||||
function firstHeader(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function reasonOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대폰 앱 받기 (사내 배포 — 계약 1절 "휴대폰 앱 받기") — 직원은 최신 판 정보 · 파일을 받고(로그인 필요), 운영 도구가 판을 올린다.
|
||||
* "최신" = 그 플랫폼에서 가장 늦게 올린 판(올린 시각 — 버전 번호가 아니다). 옛 APK 를 다시 올리면 그것이 최신이 된다(되돌리기 길).
|
||||
*
|
||||
* 파일 응답은 대용량 첨부 받기(file-sharing.service.ts 의 serveFile)와 같은 꼴이다 — 머리글은 shared/utils/download-headers.ts, 범위는 http-range.ts.
|
||||
* 로그: 내려받기 성공은 남기지 않는다. 표에는 있는데 파일이 없거나 크기가 다르면 error(운영자가 다시 올린다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppReleasesService implements BeforeApplicationShutdown {
|
||||
private readonly logger = new Logger(AppReleasesService.name);
|
||||
/** 지금 흘려보내는 파일 응답 — 서버 종료 때 끊는다 */
|
||||
private readonly streaming = new Set<Response>();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor(
|
||||
private readonly repository: AppReleasesRepository,
|
||||
private readonly storage: AppReleaseStorage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 서버 종료(HTTP 서버를 닫기 전) — 흘려보내는 파일 응답을 끊는다. 느린 내려받기가 종료 유예(30초)를 다 쓰지 않게 —
|
||||
* 브라우저는 Range 로 이어 받는다(대용량 첨부 받기와 같은 결). 그 뒤에 온 받기는 시작하지 않는다
|
||||
*/
|
||||
beforeApplicationShutdown(): void {
|
||||
this.shuttingDown = true;
|
||||
for (const res of this.streaming) res.destroy();
|
||||
this.streaming.clear();
|
||||
}
|
||||
|
||||
// ── 직원 (로그인 필요 — app-releases.controller.ts) ────────────────────────────
|
||||
|
||||
/** 최신 판 정보 — 올린 판이 없으면 404 RES_001 */
|
||||
async latestInfo(platform: AppReleasePlatform): Promise<AppReleaseInfo> {
|
||||
const release = await this.requireLatest(platform);
|
||||
return {
|
||||
version: release.version,
|
||||
sizeBytes: release.sizeBytes,
|
||||
releasedAt: release.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 최신 판 파일을 내려준다 (GET · HEAD). 실패는 머리글을 쓰기 **전에** 던진다(전역 필터가 표준 실패 형식으로):
|
||||
* 올린 판이 없음 · 파일이 없거나 표의 크기와 다름 404 RES_001.
|
||||
* 범위: 없으면 200 전체, 하나면 206, 처음이 크기 이상이면 416(본문 없음 — 래핑 예외). HEAD 는 머리글만
|
||||
*/
|
||||
async serveLatestFile(
|
||||
req: Request,
|
||||
res: Response,
|
||||
platform: AppReleasePlatform,
|
||||
): Promise<void> {
|
||||
const release = await this.requireLatest(platform);
|
||||
const file = await this.openFileOf(release);
|
||||
let handedOver = false;
|
||||
try {
|
||||
const size = release.sizeBytes;
|
||||
const etag = `"${release.id}"`;
|
||||
const decision = decideRange(
|
||||
firstHeader(req.headers.range),
|
||||
firstHeader(req.headers['if-range']),
|
||||
size,
|
||||
{ etag, lastModified: release.createdAt },
|
||||
);
|
||||
this.writeFileHeaders(res, release, etag);
|
||||
if (decision.kind === 'unsatisfiable') {
|
||||
res.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
|
||||
res.setHeader('Content-Range', `bytes */${size}`);
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const range =
|
||||
decision.kind === 'partial'
|
||||
? { start: decision.start, end: decision.end }
|
||||
: undefined;
|
||||
res.status(range ? HttpStatus.PARTIAL_CONTENT : HttpStatus.OK);
|
||||
res.setHeader(
|
||||
'Content-Length',
|
||||
String(range ? range.end - range.start + 1 : size),
|
||||
);
|
||||
if (range) {
|
||||
res.setHeader(
|
||||
'Content-Range',
|
||||
`bytes ${range.start}-${range.end}/${size}`,
|
||||
);
|
||||
}
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (this.shuttingDown) {
|
||||
// 종료 중 — 시작하지 않는다. 연결을 끊으면 받는 쪽이 다시 시도한다
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
// 흐름이 끝나거나 망가지면 파일도 닫는다 (autoClose 기본값)
|
||||
const stream = file.handle.createReadStream(
|
||||
range ? { start: range.start, end: range.end } : {},
|
||||
);
|
||||
handedOver = true;
|
||||
await this.stream(stream, res, release);
|
||||
} finally {
|
||||
if (!handedOver) await file.handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 운영 도구 (modules/ops/app-release-commands.ts) ─────────────────────────────
|
||||
|
||||
/** 운영자가 넣은 경로의 APK 를 검사한다 — 통과하면 연 파일째(부르는 쪽이 닫는다) */
|
||||
inspect(path: string): Promise<ApkInspection> {
|
||||
return inspectApk(path);
|
||||
}
|
||||
|
||||
/** 그 플랫폼의 최신 판 — 없으면 null */
|
||||
latest(platform: AppReleasePlatform): Promise<AppReleaseEntity | null> {
|
||||
return this.repository.latest(platform);
|
||||
}
|
||||
|
||||
/** 늦게 올린 것부터 `limit` 개 — 디스크에 파일이 있는지 함께 */
|
||||
async listRecent(
|
||||
platform: AppReleasePlatform,
|
||||
limit: number,
|
||||
): Promise<AppReleaseListing[]> {
|
||||
const releases = await this.repository.recent(platform, limit);
|
||||
return Promise.all(
|
||||
releases.map(async (release) => ({
|
||||
release,
|
||||
hasFile: await this.storage.exists(release.fileKey),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 판을 올린다 — 검사한 APK 를 볼륨에 들이고(임시 이름 → 디스크에 내려쓰기 → 이름 바꾸기) 표에 한 줄 넣는다. 넣은 순간 최신이 된다.
|
||||
* 들이는 동안 원본이 바뀌었으면 AppReleaseFileChangedError(파일도 표도 그대로). 표에 넣지 못하면 들인 파일을 지우고 던진다 —
|
||||
* 반쯤 올린 판을 남기지 않는다. 시작할 때 멈춘 올리기가 남긴 오래된 임시 파일을 지운다
|
||||
*/
|
||||
async publish(
|
||||
apk: InspectedApk,
|
||||
input: AppReleasePublish,
|
||||
now: Date = new Date(),
|
||||
): Promise<AppReleaseEntity> {
|
||||
if (!isAppVersion(input.version)) {
|
||||
// 부르는 쪽(운영 도구)이 먼저 본다 — 여기까지 오면 버그
|
||||
throw new Error('휴대폰 앱 버전 형식이 올바르지 않습니다.');
|
||||
}
|
||||
await this.removeStaleParts(now);
|
||||
const release = Object.assign(new AppReleaseEntity(), {
|
||||
id: randomUUID(),
|
||||
platform: input.platform,
|
||||
version: input.version,
|
||||
sizeBytes: apk.sizeBytes,
|
||||
sha256: apk.sha256,
|
||||
fileKey: randomUUID(),
|
||||
uploadedBy: input.uploadedBy,
|
||||
createdAt: now,
|
||||
} satisfies AppReleaseEntity);
|
||||
await this.storage.importFile(apk.handle, release.fileKey, {
|
||||
sizeBytes: apk.sizeBytes,
|
||||
sha256: apk.sha256,
|
||||
});
|
||||
try {
|
||||
await this.repository.insert(release);
|
||||
} catch (error) {
|
||||
// 표에 없는 파일은 누구도 받지 않는다 — 지워 둔다
|
||||
await this.storage
|
||||
.remove(release.fileKey)
|
||||
.catch((removeError: unknown) =>
|
||||
this.logger.error(
|
||||
`[SYS_001] 표에 넣지 못한 휴대폰 앱 파일을 지우지도 못했습니다 — 볼륨에 남습니다: file=${release.fileKey}, reason=${reasonOf(removeError)}`,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
this.logger.log(
|
||||
`휴대폰 앱을 올렸습니다: platform=${release.platform}, version=${release.version}, release=${release.id}, size=${release.sizeBytes}`,
|
||||
);
|
||||
return release;
|
||||
}
|
||||
|
||||
// ── 내부 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private async requireLatest(
|
||||
platform: AppReleasePlatform,
|
||||
): Promise<AppReleaseEntity> {
|
||||
const release = await this.repository.latest(platform);
|
||||
if (release === null) {
|
||||
throw new BusinessException('RES_001', {
|
||||
context: { reason: 'app-release-none', platform },
|
||||
});
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
/** 그 판의 파일 — 없거나 표의 크기와 다르면 error 로그 + 404 RES_001 (직원에게는 올린 판이 없는 것과 같다) */
|
||||
private async openFileOf(
|
||||
release: AppReleaseEntity,
|
||||
): Promise<OpenedAppReleaseFile> {
|
||||
const file = await this.storage.open(release.fileKey);
|
||||
if (file !== null && file.sizeBytes === release.sizeBytes) return file;
|
||||
if (file !== null) await file.handle.close();
|
||||
const missing = file === null;
|
||||
this.logger.error(
|
||||
`[RES_001]${traceTag()} 휴대폰 앱 파일을 내려줄 수 없습니다(${missing ? '파일 없음' : '크기가 표와 다름'}) — 운영 도구로 다시 올려 주세요: release=${release.id}, platform=${release.platform}, version=${release.version}`,
|
||||
);
|
||||
throw new BusinessException('RES_001', {
|
||||
context: {
|
||||
reason: missing
|
||||
? 'app-release-file-missing'
|
||||
: 'app-release-file-size-mismatch',
|
||||
release: release.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 파일 응답의 머리글 — 대용량 첨부 받기와 같은 꼴이고 형식만 APK 다 (shared/utils/download-headers.ts) */
|
||||
private writeFileHeaders(
|
||||
res: Response,
|
||||
release: AppReleaseEntity,
|
||||
etag: string,
|
||||
): void {
|
||||
const fileName = isAppVersion(release.version)
|
||||
? appReleaseFileName(release.version)
|
||||
: FALLBACK_FILE_NAME;
|
||||
for (const [header, value] of downloadHeaders({
|
||||
contentType: APK_CONTENT_TYPE,
|
||||
// 이름은 검사한 버전으로 만든 ASCII 뿐이다 — RFC 5987 이름(filename*)이 필요 없다
|
||||
contentDisposition: `attachment; filename="${fileName}"`,
|
||||
etag,
|
||||
lastModified: release.createdAt,
|
||||
})) {
|
||||
res.setHeader(header, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async stream(
|
||||
stream: Readable,
|
||||
res: Response,
|
||||
release: AppReleaseEntity,
|
||||
): Promise<void> {
|
||||
// 흐름 오류는 처리기가 끝난 뒤에 올 수 있다 — 추적 ID 를 지금 잡아 둔다
|
||||
const traceId = currentRequestId() ?? 'unknown';
|
||||
this.streaming.add(res);
|
||||
try {
|
||||
// 파일을 여는 사이 종료가 시작됐으면 흘려보내지 않는다 (종료 때 끊을 목록에 들기 전이었다)
|
||||
if (this.shuttingDown) res.destroy();
|
||||
await pipeline(stream, res);
|
||||
} catch (error) {
|
||||
// 받는 쪽이 끊었거나 종료로 끊었다 — 장애가 아니다. 디스크 오류 등만 남긴다
|
||||
if (!isStreamAbortError(error)) {
|
||||
this.logger.error(
|
||||
`[SYS_001][${traceId}] 휴대폰 앱 전송이 멈췄습니다: release=${release.id}, reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
if (!res.destroyed) res.destroy();
|
||||
} finally {
|
||||
this.streaming.delete(res);
|
||||
}
|
||||
}
|
||||
|
||||
/** 멈춘 올리기가 남긴 오래된 임시 파일 — 정리하지 못해도 올리기는 이어 한다 */
|
||||
private async removeStaleParts(now: Date): Promise<void> {
|
||||
try {
|
||||
const removed = await this.storage.removeStaleParts(
|
||||
new Date(now.getTime() - APP_RELEASE_STALE_PART_MS),
|
||||
);
|
||||
if (removed > 0) {
|
||||
this.logger.warn(
|
||||
`멈춘 휴대폰 앱 올리기가 남긴 임시 파일 ${removed}개를 지웠습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`멈춘 휴대폰 앱 올리기의 임시 파일을 정리하지 못했습니다 — 올리기는 이어 합니다: reason=${reasonOf(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AppReleaseEntity } from './app-release.entity';
|
||||
import type { AppReleasePlatform } from './app-releases.constants';
|
||||
|
||||
/** 최신 판 정보 — `GET /api/app-releases/android/latest` 의 data (계약 1절 "휴대폰 앱 받기") */
|
||||
export interface AppReleaseInfo {
|
||||
readonly version: string;
|
||||
readonly sizeBytes: number;
|
||||
/** 올린 때 — ISO 8601 UTC */
|
||||
readonly releasedAt: string;
|
||||
}
|
||||
|
||||
/** 운영 도구가 올리는 판 */
|
||||
export interface AppReleasePublish {
|
||||
readonly platform: AppReleasePlatform;
|
||||
readonly version: string;
|
||||
/** 운영자 이름 — 운영 도구를 시작할 때 넣은 이름 */
|
||||
readonly uploadedBy: string;
|
||||
}
|
||||
|
||||
/** 운영 도구 「휴대폰 앱 목록」의 한 줄 — 디스크에 파일이 있는지 함께 */
|
||||
export interface AppReleaseListing {
|
||||
readonly release: AppReleaseEntity;
|
||||
readonly hasFile: boolean;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import type { AppReleaseInfo } from '../app-releases.types';
|
||||
|
||||
/** GET /api/app-releases/android/latest 의 응답 (계약 1절 "휴대폰 앱 받기") */
|
||||
export class AppReleaseDto implements AppReleaseInfo {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'버전 — 앱의 versionName(`숫자.숫자.숫자`, 칸마다 1~4자리 · 앞자리 0 없이). 내려받는 파일 이름은 `ARA-SYSTEM-<버전>.apk`',
|
||||
example: '1.0.0',
|
||||
})
|
||||
version!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'APK 파일 크기(바이트)',
|
||||
example: 32_505_856,
|
||||
})
|
||||
sizeBytes!: number;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'올린 때 (ISO 8601 UTC) — 화면의 "올린 날". 최신 = 가장 늦게 올린 판(버전 번호가 아니다)',
|
||||
example: '2026-11-18T01:23:45.000Z',
|
||||
})
|
||||
releasedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { startCpuTimer } from '@/../test/cpu-time';
|
||||
import { parseBearerAuthorization, presentedAccessToken } from './access-token';
|
||||
|
||||
describe('parseBearerAuthorization (앱의 Authorization: Bearer — 계약 1절 "앱 로그인")', () => {
|
||||
it('Bearer 면 토큰 — 방식은 대소문자를 가리지 않고, 사이의 공백 여럿 · 앞뒤 공백은 지운다', () => {
|
||||
expect(parseBearerAuthorization('Bearer a.b.c')).toBe('a.b.c');
|
||||
expect(parseBearerAuthorization('bearer a.b.c')).toBe('a.b.c');
|
||||
expect(parseBearerAuthorization('BEARER a.b.c ')).toBe('a.b.c');
|
||||
});
|
||||
|
||||
it('머리가 없거나 다른 방식(Basic 등)이면 null — 쿠키를 본다', () => {
|
||||
expect(parseBearerAuthorization(undefined)).toBeNull();
|
||||
expect(parseBearerAuthorization('')).toBeNull();
|
||||
expect(parseBearerAuthorization('Basic dXNlcjpwdw==')).toBeNull();
|
||||
expect(parseBearerAuthorization('Bearers a.b.c')).toBeNull();
|
||||
});
|
||||
|
||||
it("Bearer 인데 토큰이 비었거나 공백이 섞였으면 '' — Bearer 로 왔으니 쿠키로 넘어가지 않는다", () => {
|
||||
expect(parseBearerAuthorization('Bearer')).toBe('');
|
||||
expect(parseBearerAuthorization('Bearer ')).toBe('');
|
||||
expect(parseBearerAuthorization('Bearer a.b.c d.e.f')).toBe('');
|
||||
});
|
||||
|
||||
it('아주 긴 머리도 길이에 비례해 끝난다 (머리 상한 16KB 의 몇 배)', () => {
|
||||
const long = `Bearer ${'a'.repeat(64_000)}`;
|
||||
const spaced = `Bearer ${' '.repeat(64_000)}x ${'b'.repeat(64_000)}`;
|
||||
const cpuMs = startCpuTimer();
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
parseBearerAuthorization(long);
|
||||
parseBearerAuthorization(spaced);
|
||||
}
|
||||
// 스레드 CPU 시간 — 지금 4~6ms(개발 컨테이너). 한도는 그 10배 남짓이고, 제곱이면 64KB 에서 수십 초가 든다 (testing 규칙)
|
||||
expect(cpuMs()).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('presentedAccessToken (가드가 읽는 액세스 토큰과 그 길)', () => {
|
||||
it('Authorization: Bearer 가 있으면 그것만 본다 — 같은 요청의 액세스 쿠키는 보지 않는다', () => {
|
||||
expect(
|
||||
presentedAccessToken({
|
||||
headers: { authorization: 'Bearer app.token' },
|
||||
cookies: { access_token: 'web.token' },
|
||||
}),
|
||||
).toEqual({ transport: 'bearer', token: 'app.token' });
|
||||
// 토큰이 빈 Bearer 도 쿠키로 넘어가지 않는다 — 토큰 없음(가드가 AUTH_001)
|
||||
expect(
|
||||
presentedAccessToken({
|
||||
headers: { authorization: 'Bearer ' },
|
||||
cookies: { access_token: 'web.token' },
|
||||
}),
|
||||
).toEqual({ transport: 'bearer', token: null });
|
||||
});
|
||||
|
||||
it('Bearer 가 없으면 액세스 쿠키(웹) — 다른 방식의 Authorization 은 상관하지 않는다', () => {
|
||||
expect(
|
||||
presentedAccessToken({
|
||||
headers: { authorization: 'Basic dXNlcjpwdw==' },
|
||||
cookies: { access_token: 'web.token' },
|
||||
}),
|
||||
).toEqual({ transport: 'cookie', token: 'web.token' });
|
||||
expect(presentedAccessToken({ headers: {}, cookies: {} })).toEqual({
|
||||
transport: 'cookie',
|
||||
token: null,
|
||||
});
|
||||
expect(presentedAccessToken({ headers: {} })).toEqual({
|
||||
transport: 'cookie',
|
||||
token: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE } from './auth.constants';
|
||||
|
||||
/**
|
||||
* 요청이 액세스 토큰을 실어 온 길 (계약 1절 "앱 로그인") — 웹은 HttpOnly 쿠키, 앱은 `Authorization: Bearer`.
|
||||
* 가드(JwtStrategy)는 길과 토큰의 표시(`cli`)가 맞는지 본다 — 앱 토큰은 Bearer 로만, 웹 토큰은 쿠키로만 받는다
|
||||
*/
|
||||
export type AccessTokenTransport = 'cookie' | 'bearer';
|
||||
|
||||
/** 요청이 실어 온 액세스 토큰 — 그 길에 토큰이 없거나 모양이 틀리면 token 은 null */
|
||||
export interface PresentedAccessToken {
|
||||
readonly transport: AccessTokenTransport;
|
||||
readonly token: string | null;
|
||||
}
|
||||
|
||||
/** 요청 대역 — 머리글과 cookie-parser 가 채운 쿠키만 본다 */
|
||||
export interface AccessTokenRequest {
|
||||
readonly headers: IncomingHttpHeaders;
|
||||
readonly cookies?: unknown;
|
||||
}
|
||||
|
||||
/** Authorization 머리의 인증 방식 — 대소문자를 가리지 않는다 (RFC 9110) */
|
||||
const BEARER_SCHEME = 'bearer';
|
||||
/** 공백 한 글자 — 방식과 토큰 사이 (정규식은 한 글자씩만 본다 — 입력 길이에 비례) */
|
||||
const WHITESPACE = /\s/;
|
||||
|
||||
/**
|
||||
* Authorization 머리 → Bearer 토큰.
|
||||
* - Bearer 가 아니면(머리 없음 · Basic 등 다른 방식) null — 쿠키를 본다
|
||||
* - Bearer 인데 토큰이 비었거나 공백이 섞였으면 '' — Bearer 로 왔으니 쿠키로 넘어가지 않는다(가드가 AUTH_001)
|
||||
* 토큰 원문은 어디에도 남기지 않는다
|
||||
*/
|
||||
export function parseBearerAuthorization(
|
||||
header: string | undefined,
|
||||
): string | null {
|
||||
if (header === undefined) return null;
|
||||
const value = header.trim();
|
||||
const space = value.search(WHITESPACE);
|
||||
const scheme = space === -1 ? value : value.slice(0, space);
|
||||
if (scheme.toLowerCase() !== BEARER_SCHEME) return null;
|
||||
const credentials = space === -1 ? '' : value.slice(space).trim();
|
||||
return WHITESPACE.test(credentials) ? '' : credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청이 실어 온 액세스 토큰 — `Authorization: Bearer` 가 있으면 그것만 본다(앱 — 같은 요청의 쿠키는 보지 않는다),
|
||||
* 없으면 액세스 쿠키(웹). 두 길을 섞어 읽지 않아 한쪽이 틀리면 다른 쪽으로 넘어가지 않는다
|
||||
*/
|
||||
export function presentedAccessToken(
|
||||
request: AccessTokenRequest,
|
||||
): PresentedAccessToken {
|
||||
const bearer = parseBearerAuthorization(request.headers.authorization);
|
||||
if (bearer !== null) {
|
||||
return { transport: 'bearer', token: bearer === '' ? null : bearer };
|
||||
}
|
||||
return {
|
||||
transport: 'cookie',
|
||||
token: readCookie(request, ACCESS_TOKEN_COOKIE) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { GUARDS_METADATA, HEADERS_METADATA } from '@nestjs/common/constants';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { AppAuthController } from './app-auth.controller';
|
||||
import { AuthController } from './auth.controller';
|
||||
import type { AuthService } from './auth.service';
|
||||
|
||||
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
|
||||
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' };
|
||||
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null };
|
||||
|
||||
/** 서비스가 만든 두 토큰 (SessionTokens) */
|
||||
const TOKENS = {
|
||||
accessToken: 'new.access',
|
||||
refreshToken: 'new.refresh',
|
||||
refreshTtlSeconds: 1_209_600,
|
||||
accessExpiresAt: new Date('2026-11-06T03:00:00.000Z'),
|
||||
refreshExpiresAt: new Date('2026-11-20T02:00:00.000Z'),
|
||||
};
|
||||
/** 앱 응답의 tokens — 시각은 ISO 8601 UTC */
|
||||
const APP_TOKENS = {
|
||||
accessToken: 'new.access',
|
||||
accessExpiresAt: '2026-11-06T03:00:00.000Z',
|
||||
refreshToken: 'new.refresh',
|
||||
refreshExpiresAt: '2026-11-20T02:00:00.000Z',
|
||||
};
|
||||
|
||||
/** 요청 대역 — Authorization · User-Agent 머리글 */
|
||||
function fakeRequest(headers: Record<string, string> = {}) {
|
||||
return {
|
||||
headers,
|
||||
get: (name: string) => headers[name.toLowerCase()],
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드 · 한도 · 머리글)를 이 함수에 붙인다 */
|
||||
function handlerOf(
|
||||
target: { prototype: object },
|
||||
name: string,
|
||||
): (...args: unknown[]) => unknown {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target.prototype, name);
|
||||
return descriptor?.value as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
describe('AppAuthController (앱 전용 토큰 경로 — 계약 1절 "앱 로그인")', () => {
|
||||
let auth: { login: jest.Mock; refresh: jest.Mock; logoutApp: jest.Mock };
|
||||
let controller: AppAuthController;
|
||||
|
||||
beforeEach(() => {
|
||||
auth = {
|
||||
login: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logoutApp: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
controller = new AppAuthController(auth as unknown as AuthService);
|
||||
});
|
||||
|
||||
it('가드가 없다 — 로그인 전 · 액세스 토큰이 끝난 뒤에 부른다', () => {
|
||||
for (const name of ['login', 'refresh', 'logout']) {
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
handlerOf(AppAuthController, name),
|
||||
),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, AppAuthController),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('토큰이 든 응답(로그인 · 갱신)은 Cache-Control: private, no-store', () => {
|
||||
for (const name of ['login', 'refresh']) {
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
HEADERS_METADATA,
|
||||
handlerOf(AppAuthController, name),
|
||||
),
|
||||
).toEqual([{ name: 'Cache-Control', value: 'private, no-store' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('로그인 요청 수 상한은 웹 로그인과 같은 값이고 같은 칸을 센다 — 같은 IP 면 두 경로의 키가 같다', () => {
|
||||
const app = handlerOf(AppAuthController, 'login');
|
||||
const web = handlerOf(AuthController, 'login');
|
||||
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', app)).toBe(60);
|
||||
expect(Reflect.getMetadata('THROTTLER:TTLdefault', app)).toBe(60_000);
|
||||
|
||||
type KeyGenerator = (
|
||||
context: unknown,
|
||||
tracker: string,
|
||||
name: string,
|
||||
) => string;
|
||||
const appKey = Reflect.getMetadata(
|
||||
'THROTTLER:KEY_GENERATORdefault',
|
||||
app,
|
||||
) as KeyGenerator;
|
||||
const webKey = Reflect.getMetadata(
|
||||
'THROTTLER:KEY_GENERATORdefault',
|
||||
web,
|
||||
) as KeyGenerator;
|
||||
expect(appKey({}, '203.0.113.10', 'default')).toBe(
|
||||
webKey({}, '203.0.113.10', 'default'),
|
||||
);
|
||||
expect(appKey({}, '203.0.113.10', 'default')).not.toBe(
|
||||
appKey({}, '203.0.113.11', 'default'),
|
||||
);
|
||||
// 다른 앱 경로는 전역 기본값
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
'THROTTLER:LIMITdefault',
|
||||
handlerOf(AppAuthController, 'refresh'),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('로그인은 웹과 같은 서비스 함수에 앱 표시(sessionClient app) · User-Agent · 코드를 넘기고, signedIn 이면 tokens 를 싣는다', async () => {
|
||||
auth.login.mockResolvedValue({
|
||||
status: 'signedIn',
|
||||
user: USER,
|
||||
tokens: TOKENS,
|
||||
state: { restriction: 'passwordChange', twoFactorDeadline: null },
|
||||
});
|
||||
|
||||
const result = await controller.login(
|
||||
{ email: USER.email, password: 'pw', otpCode: '123456' },
|
||||
'203.0.113.10',
|
||||
fakeRequest({ 'user-agent': APP_UA }),
|
||||
);
|
||||
|
||||
expect(auth.login).toHaveBeenCalledWith(USER.email, 'pw', '203.0.113.10', {
|
||||
otpCode: '123456',
|
||||
userAgent: APP_UA,
|
||||
sessionClient: 'app',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: 'signedIn',
|
||||
...USER,
|
||||
restriction: 'passwordChange',
|
||||
twoFactorDeadline: null,
|
||||
tokens: APP_TOKENS,
|
||||
});
|
||||
});
|
||||
|
||||
it('2단계 인증이 필요하면 tokens: null — 웹처럼 email · name 도 null', async () => {
|
||||
auth.login.mockResolvedValue({
|
||||
status: 'twoFactorRequired',
|
||||
user: null,
|
||||
tokens: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.login(
|
||||
{ email: USER.email, password: 'pw' },
|
||||
'203.0.113.10',
|
||||
fakeRequest(),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
email: null,
|
||||
name: null,
|
||||
...UNRESTRICTED,
|
||||
tokens: null,
|
||||
});
|
||||
expect(auth.login).toHaveBeenCalledWith(USER.email, 'pw', '203.0.113.10', {
|
||||
otpCode: undefined,
|
||||
userAgent: null,
|
||||
sessionClient: 'app',
|
||||
});
|
||||
});
|
||||
|
||||
it('갱신은 본문의 리프레시 토큰을 앱 클라이언트로 넘긴다 — 회전이면 tokens, 유예면 tokens: null', async () => {
|
||||
auth.refresh.mockResolvedValueOnce({
|
||||
user: USER,
|
||||
tokens: TOKENS,
|
||||
state: UNRESTRICTED,
|
||||
});
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ refreshToken: 'old.refresh' },
|
||||
'203.0.113.10',
|
||||
fakeRequest({ 'user-agent': APP_UA }),
|
||||
),
|
||||
).resolves.toEqual({ ...USER, ...UNRESTRICTED, tokens: APP_TOKENS });
|
||||
expect(auth.refresh).toHaveBeenCalledWith('old.refresh', {
|
||||
ip: '203.0.113.10',
|
||||
userAgent: APP_UA,
|
||||
sessionClient: 'app',
|
||||
});
|
||||
|
||||
auth.refresh.mockResolvedValueOnce({
|
||||
user: USER,
|
||||
tokens: null,
|
||||
state: UNRESTRICTED,
|
||||
});
|
||||
await expect(
|
||||
controller.refresh(
|
||||
{ refreshToken: 'old.refresh' },
|
||||
'203.0.113.10',
|
||||
fakeRequest(),
|
||||
),
|
||||
).resolves.toEqual({ ...USER, ...UNRESTRICTED, tokens: null });
|
||||
});
|
||||
|
||||
it('로그아웃은 본문의 리프레시 토큰과 Bearer 액세스 토큰을 서비스에 넘기고 늘 null 이다 — Bearer 가 아니거나 비었으면 넘기지 않는다', async () => {
|
||||
await expect(
|
||||
controller.logout(
|
||||
{ refreshToken: 'old.refresh' },
|
||||
fakeRequest({ authorization: 'Bearer old.access' }),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
expect(auth.logoutApp).toHaveBeenLastCalledWith(
|
||||
'old.refresh',
|
||||
'old.access',
|
||||
);
|
||||
|
||||
for (const authorization of [undefined, 'Bearer ', 'Basic dXNlcjpwdw==']) {
|
||||
await controller.logout(
|
||||
{},
|
||||
fakeRequest(authorization === undefined ? {} : { authorization }),
|
||||
);
|
||||
expect(auth.logoutApp).toHaveBeenLastCalledWith(undefined, undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { parseBearerAuthorization } from './access-token';
|
||||
import type { SessionTokens } from './auth-cookies';
|
||||
import { LOGIN_THROTTLE } from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
AppLoginResultDto,
|
||||
AppLogoutDto,
|
||||
AppRefreshDto,
|
||||
AppRefreshResultDto,
|
||||
type AppTokensDto,
|
||||
} from './dto/app-auth.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/** 서버가 만든 두 토큰 → 앱 응답의 tokens (끝나는 때는 ISO 8601 UTC — 토큰의 exp 와 같다) */
|
||||
function toAppTokens(tokens: SessionTokens): AppTokensDto {
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
accessExpiresAt: tokens.accessExpiresAt.toISOString(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
refreshExpiresAt: tokens.refreshExpiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱(모바일) 전용 토큰 경로 (계약 1절 "앱 로그인") — 세션 규칙은 웹과 같고(AuthService 하나), 토큰을 나르는 길만 다르다:
|
||||
* 쿠키 대신 응답 본문의 `tokens` 로 주고 본문 · `Authorization: Bearer` 로 받는다. 앱 토큰에는 표시(cli: app)가 있어 웹 경로 · 쿠키로는 받지 않는다.
|
||||
*
|
||||
* - 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 엔진 관리자 차단은 웹 로그인과 같은 함수를 지나고, 요청 수 한도는 웹 로그인과 같은 칸을 함께 센다
|
||||
* - 토큰이 든 응답(로그인 · 갱신)은 Cache-Control: private, no-store
|
||||
* - 가드가 없다 — 로그인 전 · 액세스 토큰이 끝난 뒤에 부른다. 출처 검사(OriginGuard)는 앱(Origin · Sec-Fetch-Site 없음)을 지금 규칙대로 통과시킨다
|
||||
* 판단은 모두 AuthService 가 한다. 토큰 · 비밀번호는 로그 · 오류 context 에 남기지 않는다
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth/app')
|
||||
export class AppAuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '앱 로그인 — 성공하면 두 토큰을 본문(tokens)으로 준다',
|
||||
description:
|
||||
'본문 · 응답 칸은 웹 로그인(POST /api/auth/login)과 같고 tokens 가 더해진다 — signedIn 에만 차고 twoFactorRequired 는 null(쿠키는 굽지 않는다). ' +
|
||||
'2단계 인증 · 앱 비밀번호 거절 · 실패 한도(같은 IP 1분 실패 20회 · 같은 주소 15분 실패 10회 · 코드 실패 같은 주소 15분 5회 · 24시간 20회)는 웹과 같은 함수를 지나고 웹과 함께 센다. ' +
|
||||
'요청 수 상한(IP 당 1분 60회)도 웹 로그인과 같은 칸이다. 로그인 기록의 방식은 app, 로그인된 기기에는 User-Agent(ARASYSTEM-App/…)로 "ARA SYSTEM 앱"이 보인다. ' +
|
||||
'응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(AppLoginResultDto, {
|
||||
description:
|
||||
'로그인 성공(signedIn — tokens) 또는 2단계 인증 코드 필요(twoFactorRequired — tokens 는 null)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'AUTH_004',
|
||||
'계정 없음·비밀번호 틀림(코드 단계에서도)·앱 비밀번호·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다 — 웹과 같다)',
|
||||
)
|
||||
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_006',
|
||||
'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 형식 오류 (모두 400)',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'REQ_001',
|
||||
'시도 과다 — 웹 로그인과 같은 실패 한도 · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청(웹 로그인과 함께 센다)',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Ip() ip: string,
|
||||
@Req() req: Request,
|
||||
): Promise<AppLoginResultDto> {
|
||||
const result = await this.auth.login(dto.email, dto.password, ip, {
|
||||
otpCode: dto.otpCode,
|
||||
userAgent: req.get('user-agent') ?? null,
|
||||
sessionClient: 'app',
|
||||
});
|
||||
if (result.status === 'twoFactorRequired') {
|
||||
return {
|
||||
status: result.status,
|
||||
email: null,
|
||||
name: null,
|
||||
restriction: null,
|
||||
twoFactorDeadline: null,
|
||||
tokens: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: result.status,
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
restriction: result.state.restriction,
|
||||
twoFactorDeadline: result.state.twoFactorDeadline,
|
||||
tokens: toAppTokens(result.tokens),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'앱 토큰 회전 — 본문의 리프레시 토큰으로 두 토큰을 새로 준다 (1회용)',
|
||||
description:
|
||||
'웹 갱신(POST /api/auth/refresh)과 같은 세션 규칙 — 이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. ' +
|
||||
'회전 직후 30초 안에 바로 이전 토큰이 다시 오면 새 토큰 없이 200(tokens: null)이고, 앱 세션은 그 세션을 끝낸다 ' +
|
||||
'(앱은 갱신을 한 번에 하나만 하므로 회전 응답을 잃었거나 토큰이 두 번 쓰였다 — 이어 쓸 토큰이 없다). 응답 칸(restriction · twoFactorDeadline)은 늘 싣는다. ' +
|
||||
'웹(쿠키)의 리프레시 토큰은 받지 않는다. 빈 글 · 2,048자를 넘는 토큰은 서명을 보기 전에 401 이다. 응답은 Cache-Control: private, no-store.',
|
||||
})
|
||||
@ApiStandardResponse(AppRefreshResultDto, {
|
||||
description:
|
||||
'회전 성공(tokens) 또는 갱신 유예(tokens: null — 그 앱 세션은 끝났다)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'리프레시 토큰 빈 글·2,048자 넘음·만료·위조·웹 토큰·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 앱은 토큰을 지우고 로그인 화면으로',
|
||||
)
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'본문 형식 오류 (refreshToken 이 없거나 글이 아님 · 모르는 칸)',
|
||||
)
|
||||
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 세션은 그대로 둔다')
|
||||
@ApiErrorResponse(
|
||||
'SYS_001',
|
||||
'서버 쪽 드문 불일치(회전 충돌) — 세션은 그대로, 같은 토큰으로 다시 시도할 수 있다 · ' +
|
||||
'앱 세션의 유예에서 세션 끊기에 실패해도 500 — 다시 보내면 끊는다',
|
||||
)
|
||||
async refresh(
|
||||
@Body() dto: AppRefreshDto,
|
||||
@Ip() ip: string,
|
||||
@Req() req: Request,
|
||||
): Promise<AppRefreshResultDto> {
|
||||
const result = await this.auth.refresh(dto.refreshToken, {
|
||||
ip,
|
||||
userAgent: req.get('user-agent') ?? null,
|
||||
sessionClient: 'app',
|
||||
});
|
||||
return {
|
||||
email: result.user.email,
|
||||
name: result.user.name,
|
||||
restriction: result.state.restriction,
|
||||
twoFactorDeadline: result.state.twoFactorDeadline,
|
||||
tokens: result.tokens ? toAppTokens(result.tokens) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({
|
||||
summary: '앱 로그아웃 — 서버의 세션을 끝낸다 (본문 모양이 맞으면 늘 200)',
|
||||
description:
|
||||
'본문의 리프레시 토큰으로 세션을 찾고, 없거나 · 빈 글 · 2,048자 넘음 · 틀리면 `Authorization: Bearer` 액세스 토큰의 sid 로 끝낸다(만료된 토큰도 서명이 맞으면). ' +
|
||||
'어느 쪽도 맞지 않아도 200 이다 — 400 은 본문 모양이 틀릴 때뿐이다(refreshToken 이 글이 아님 · 모르는 칸). ' +
|
||||
'끝낸 세션의 액세스 토큰은 서버가 1시간 기억해 바로 막는다.',
|
||||
})
|
||||
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'본문 형식 오류 (refreshToken 이 글이 아님 · 모르는 칸)',
|
||||
)
|
||||
async logout(@Body() dto: AppLogoutDto, @Req() req: Request): Promise<null> {
|
||||
const bearer = parseBearerAuthorization(req.headers.authorization);
|
||||
await this.auth.logoutApp(dto.refreshToken, bearer || undefined);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,13 @@ describe('세션 쿠키', () => {
|
||||
|
||||
setSessionCookies(
|
||||
res as unknown as Response,
|
||||
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
|
||||
{
|
||||
accessToken: 'a.b.c',
|
||||
refreshToken: 'd.e.f',
|
||||
refreshTtlSeconds: 600,
|
||||
accessExpiresAt: new Date('2026-11-06T03:00:00.000Z'),
|
||||
refreshExpiresAt: new Date('2026-11-06T02:10:00.000Z'),
|
||||
},
|
||||
settings,
|
||||
);
|
||||
clearSessionCookies(res as unknown as Response, settings);
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface SessionTokens {
|
||||
readonly refreshToken: string;
|
||||
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
|
||||
readonly refreshTtlSeconds: number;
|
||||
/** 액세스 토큰이 끝나는 때 — 토큰의 exp 와 같다 (앱 응답의 accessExpiresAt — 계약 1절 "앱 로그인") */
|
||||
readonly accessExpiresAt: Date;
|
||||
/** 리프레시 토큰이 끝나는 때 — 토큰의 exp 와 같다 (앱 응답의 refreshExpiresAt) */
|
||||
readonly refreshExpiresAt: Date;
|
||||
}
|
||||
|
||||
type CookieKind = 'access' | 'refresh';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { SessionClient } from '@/common/decorators/current-user.decorator';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
@@ -14,6 +15,10 @@ const IP_COLUMN_LENGTH = 64;
|
||||
const USER_AGENT_COLUMN_LENGTH = 512;
|
||||
/** 제한 이름 길이 — 'passwordChange' · 'twoFactorSetup' (여유를 둔다) */
|
||||
const RESTRICTION_COLUMN_LENGTH = 32;
|
||||
/** 클라이언트 이름 길이 — 'web' · 'app' (여유를 둔다) */
|
||||
const CLIENT_COLUMN_LENGTH = 16;
|
||||
/** 칸을 모르는 코드(다른 브랜치 · 옛 행)가 넣은 행은 웹 세션이다 — 마이그레이션의 DB 기본값과 같다 */
|
||||
const DEFAULT_SESSION_CLIENT: SessionClient = 'web';
|
||||
|
||||
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
|
||||
export type SessionRevokedReason =
|
||||
@@ -32,7 +37,12 @@ export type SessionRevokedReason =
|
||||
* 2단계 인증을 거친 적이 없는 세션(twoFactorSetup 제한)인데 그 직원이 2단계를 켰다 — 다른 세션에서 켰을 때 · 갱신에서 계정이 이미 켜짐일 때
|
||||
* (비밀번호만 아는 다른 사람의 세션일 수 있다 — 계약 6절 "제한된 세션")
|
||||
*/
|
||||
| 'two-factor-not-verified';
|
||||
| 'two-factor-not-verified'
|
||||
/**
|
||||
* 앱 세션의 갱신 유예 — 회전 직후 바로 이전 토큰이 다시 왔다 (계약 1절 "앱 로그인").
|
||||
* 앱은 갱신을 한 번에 하나만 하므로 회전 응답을 잃었거나 훔친 토큰을 누가 먼저 썼다 — 어느 쪽이든 이어 쓸 토큰이 없어 세션을 끝낸다
|
||||
*/
|
||||
| 'app-refresh-grace';
|
||||
|
||||
/**
|
||||
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
|
||||
@@ -42,6 +52,7 @@ export type SessionRevokedReason =
|
||||
* 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다)
|
||||
* - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null
|
||||
* - 제한(restriction — 13단계)은 로그인 · 갱신 때 정하고 액세스 토큰에도 싣는다. 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 지운다 (계약 6절 "제한된 세션")
|
||||
* - 클라이언트(client — 앱 1단계)는 로그인 때 정하고 바뀌지 않는다 — 웹(쿠키) · 앱(Bearer · 본문). 갱신은 같은 클라이언트의 토큰으로만 된다 (계약 1절 "앱 로그인")
|
||||
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
|
||||
*/
|
||||
@Entity({ name: 'auth_sessions' })
|
||||
@@ -120,4 +131,15 @@ export class AuthSessionEntity {
|
||||
nullable: true,
|
||||
})
|
||||
restriction!: SessionRestriction | null;
|
||||
|
||||
/**
|
||||
* 세션을 연 클라이언트 (앱 1단계 — 계약 1절 "앱 로그인") — web(브라우저 · 쿠키) · app(모바일 앱 · Bearer).
|
||||
* DB 기본값이 'web' 이라 이 칸을 모르는 코드(다른 브랜치)가 넣은 행 · 앱 1단계 전의 행도 웹 세션이다
|
||||
*/
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: CLIENT_COLUMN_LENGTH,
|
||||
default: DEFAULT_SESSION_CLIENT,
|
||||
})
|
||||
client!: SessionClient;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const OLDER = hashRefreshToken('older.token');
|
||||
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
return {
|
||||
userEmail: ARA,
|
||||
client: 'web',
|
||||
tokenHash: CURRENT,
|
||||
previousTokenHash: PREVIOUS,
|
||||
rotatedAt: new Date(NOW.getTime() - 5_000),
|
||||
@@ -27,8 +28,12 @@ function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
|
||||
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
|
||||
const classify = (
|
||||
snapshot: SessionSnapshot | null,
|
||||
hash: string,
|
||||
now = NOW,
|
||||
client: 'web' | 'app' = 'web',
|
||||
) => classifyRefresh(snapshot, { sub: ARA, client }, hash, now, GRACE_MS);
|
||||
|
||||
describe('classifyRefresh', () => {
|
||||
it('지금 토큰이면 current', () => {
|
||||
@@ -75,6 +80,18 @@ describe('classifyRefresh', () => {
|
||||
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
|
||||
).toBe('missing');
|
||||
});
|
||||
|
||||
it('토큰과 세션의 클라이언트(웹 · 앱)가 다르면 missing — 재사용으로 보고 세션을 끊지도 않는다 (계약 1절 "앱 로그인")', () => {
|
||||
expect(classify(session(), CURRENT, NOW, 'app')).toBe('missing');
|
||||
expect(classify(session(), OLDER, NOW, 'app')).toBe('missing');
|
||||
expect(classify(session({ client: 'app' }), CURRENT, NOW, 'app')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(classify(session({ client: 'app' }), PREVIOUS, NOW, 'app')).toBe(
|
||||
'grace',
|
||||
);
|
||||
expect(classify(session({ client: 'app' }), CURRENT)).toBe('missing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashRefreshToken', () => {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
|
||||
import type { SessionClient } from '@/common/decorators/current-user.decorator';
|
||||
|
||||
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
|
||||
export interface SessionSnapshot {
|
||||
readonly userEmail: string;
|
||||
/** 세션을 연 클라이언트 — 갱신은 같은 클라이언트의 토큰으로만 된다 (계약 1절 "앱 로그인") */
|
||||
readonly client: SessionClient;
|
||||
readonly tokenHash: string;
|
||||
readonly previousTokenHash: string | null;
|
||||
readonly rotatedAt: Date | null;
|
||||
@@ -40,13 +44,19 @@ function sameHash(a: string, b: string): boolean {
|
||||
|
||||
export function classifyRefresh(
|
||||
session: SessionSnapshot | null,
|
||||
claims: { readonly sub: string },
|
||||
claims: { readonly sub: string; readonly client: SessionClient },
|
||||
presentedHash: string,
|
||||
now: Date,
|
||||
graceMs: number,
|
||||
): RefreshState {
|
||||
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
|
||||
if (!session || session.userEmail !== claims.sub) return 'missing';
|
||||
// 서명이 맞는데 주인 · 클라이언트(웹 · 앱)가 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
|
||||
if (
|
||||
!session ||
|
||||
session.userEmail !== claims.sub ||
|
||||
session.client !== claims.client
|
||||
) {
|
||||
return 'missing';
|
||||
}
|
||||
if (session.revokedAt !== null) return 'revoked';
|
||||
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
|
||||
return 'expired';
|
||||
|
||||
@@ -34,6 +34,7 @@ import { AuthSessionsService } from './auth-sessions.service';
|
||||
import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto';
|
||||
|
||||
// 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다.
|
||||
// 앱(Bearer)은 쿠키가 없어 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션").
|
||||
// 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션)
|
||||
// 13단계 — /api/auth/* 라 제한된 세션도 부른다(계약 6절 "제한된 세션")
|
||||
@ApiTags('Auth')
|
||||
@@ -47,9 +48,10 @@ export class AuthSessionsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '로그인된 브라우저 — 끝나지 않은 웹메일 세션',
|
||||
summary: '로그인된 브라우저 — 끝나지 않은 웹메일 · 앱 세션',
|
||||
description:
|
||||
'지금 세션(리프레시 쿠키가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' +
|
||||
'지금 세션(웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' +
|
||||
'client 는 세션을 연 클라이언트(web · app)이고, 앱 세션의 브라우저 이름은 ARA SYSTEM 앱이다(세션 표가 앱일 때만 — User-Agent 가 앱 꼴이어도 웹 세션에는 붙이지 않는다). ' +
|
||||
'IP · 브라우저는 로그인 · 갱신 때 서버가 둔다(12단계 전에 연 세션은 null). 위치 커서 · 기본 20 · 최대 100.',
|
||||
})
|
||||
@ApiStandardResponse(AuthSessionPageDto, { description: '한 쪽' })
|
||||
@@ -60,7 +62,7 @@ export class AuthSessionsController {
|
||||
@Req() req: Request,
|
||||
): Promise<AuthSessionPageDto> {
|
||||
const page = await this.authSessions.list(
|
||||
user.email,
|
||||
user,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
{ cursor: query.cursor, limit: query.limit },
|
||||
);
|
||||
@@ -71,6 +73,7 @@ export class AuthSessionsController {
|
||||
lastActiveAt: session.lastActiveAt.toISOString(),
|
||||
ip: session.ip,
|
||||
device: session.device,
|
||||
client: session.client,
|
||||
isCurrent: session.isCurrent,
|
||||
})),
|
||||
pageInfo: page.pageInfo,
|
||||
@@ -94,7 +97,7 @@ export class AuthSessionsController {
|
||||
@Req() req: Request,
|
||||
): Promise<null> {
|
||||
await this.authSessions.end(
|
||||
user.email,
|
||||
user,
|
||||
id,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
@@ -106,7 +109,8 @@ export class AuthSessionsController {
|
||||
@ApiOperation({
|
||||
summary: '다른 곳 모두 로그아웃 — 이 요청의 세션만 남긴다',
|
||||
description:
|
||||
'리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.',
|
||||
'리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 앱(Bearer)이 부르면 그 앱 세션(액세스 토큰의 sid)을 남긴다. ' +
|
||||
'메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.',
|
||||
})
|
||||
@ApiStandardResponse(EndedSessionsDto, { description: '끊은 세션 수' })
|
||||
async endOthers(
|
||||
@@ -114,7 +118,7 @@ export class AuthSessionsController {
|
||||
@Req() req: Request,
|
||||
): Promise<EndedSessionsDto> {
|
||||
const ended = await this.authSessions.endOthers(
|
||||
user.email,
|
||||
user,
|
||||
readCookie(req, REFRESH_TOKEN_COOKIE),
|
||||
);
|
||||
return { ended };
|
||||
|
||||
@@ -13,6 +13,9 @@ const OTHER = '22222222-2222-4222-8222-222222222222';
|
||||
const OLDER = '33333333-3333-4333-8333-333333333333';
|
||||
const CHROME =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
|
||||
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
|
||||
/** 웹(쿠키)으로 들어온 직원 — 가드가 넣은 값 */
|
||||
const USER = { email: ARA, name: '김아라', sessionId: CURRENT };
|
||||
|
||||
const session = (
|
||||
id: string,
|
||||
@@ -32,6 +35,7 @@ const session = (
|
||||
ip: '211.186.95.252',
|
||||
userAgent: CHROME,
|
||||
restriction: null,
|
||||
client: 'web',
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -48,7 +52,7 @@ async function failureOf(
|
||||
}
|
||||
|
||||
describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () => {
|
||||
let auth: { currentSessionId: jest.Mock };
|
||||
let auth: { requestSessionId: jest.Mock };
|
||||
let sessions: {
|
||||
listAliveOf: jest.Mock;
|
||||
revokeAliveOf: jest.Mock;
|
||||
@@ -57,7 +61,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
let service: AuthSessionsService;
|
||||
|
||||
beforeEach(() => {
|
||||
auth = { currentSessionId: jest.fn().mockResolvedValue(CURRENT) };
|
||||
auth = { requestSessionId: jest.fn().mockResolvedValue(CURRENT) };
|
||||
sessions = {
|
||||
listAliveOf: jest.fn().mockResolvedValue([
|
||||
session(OLDER, '2026-10-30T01:00:00.000Z', {
|
||||
@@ -80,9 +84,9 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('지금 세션이 맨 앞, 그다음 최근 활동 차례 — 브라우저 · 운영체제는 짧은 이름, 12단계 전의 세션은 null', async () => {
|
||||
const page = await service.list(ARA, 'refresh.cookie', { limit: 20 });
|
||||
const page = await service.list(USER, 'refresh.cookie', { limit: 20 });
|
||||
|
||||
expect(auth.currentSessionId).toHaveBeenCalledWith('refresh.cookie', ARA);
|
||||
expect(auth.requestSessionId).toHaveBeenCalledWith(USER, 'refresh.cookie');
|
||||
expect(page.items.map(({ id, isCurrent }) => [id, isCurrent])).toEqual([
|
||||
[CURRENT, true],
|
||||
[OTHER, false],
|
||||
@@ -92,6 +96,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
lastActiveAt: new Date('2026-10-31T02:00:00.000Z'),
|
||||
ip: '211.186.95.252',
|
||||
device: { browser: 'Chrome', os: 'Windows' },
|
||||
client: 'web',
|
||||
});
|
||||
expect(page.items[2]).toMatchObject({ ip: null, device: null });
|
||||
expect(page.pageInfo).toEqual({
|
||||
@@ -102,9 +107,9 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
});
|
||||
|
||||
it('위치 커서로 나눠 준다 — 다른 목록의 커서는 VAL_001', async () => {
|
||||
const first = await service.list(ARA, undefined, { limit: 2 });
|
||||
const first = await service.list(USER, undefined, { limit: 2 });
|
||||
expect(first.pageInfo.hasNext).toBe(true);
|
||||
const second = await service.list(ARA, undefined, {
|
||||
const second = await service.list(USER, undefined, {
|
||||
limit: 2,
|
||||
cursor: first.pageInfo.nextCursor ?? undefined,
|
||||
});
|
||||
@@ -113,7 +118,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
service.list(ARA, undefined, {
|
||||
service.list(USER, undefined, {
|
||||
limit: 2,
|
||||
cursor: encodeOffsetCursor(1, 'contacts'),
|
||||
}),
|
||||
@@ -123,13 +128,13 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
});
|
||||
|
||||
it('리프레시 쿠키가 없거나 남의 것이면 isCurrent 는 모두 false', async () => {
|
||||
auth.currentSessionId.mockResolvedValue(null);
|
||||
const page = await service.list(ARA, undefined, { limit: 20 });
|
||||
auth.requestSessionId.mockResolvedValue(null);
|
||||
const page = await service.list(USER, undefined, { limit: 20 });
|
||||
expect(page.items.every((item) => !item.isCurrent)).toBe(true);
|
||||
});
|
||||
|
||||
it('하나 끊기 — 지금 세션은 400 VAL_001, 모양이 아닌 id · 남의 것 · 없는 것 · 끝난 것은 404 RES_001', async () => {
|
||||
await expect(service.end(ARA, OTHER, 'cookie')).resolves.toBeUndefined();
|
||||
await expect(service.end(USER, OTHER, 'cookie')).resolves.toBeUndefined();
|
||||
expect(sessions.revokeAliveOf).toHaveBeenCalledWith(
|
||||
OTHER,
|
||||
ARA,
|
||||
@@ -137,20 +142,20 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
expect.any(Date),
|
||||
);
|
||||
|
||||
expect((await failureOf(service.end(ARA, CURRENT, 'cookie')))?.code).toBe(
|
||||
expect((await failureOf(service.end(USER, CURRENT, 'cookie')))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(
|
||||
(await failureOf(service.end(ARA, 'not-a-uuid', 'cookie')))?.code,
|
||||
(await failureOf(service.end(USER, 'not-a-uuid', 'cookie')))?.code,
|
||||
).toBe('RES_001');
|
||||
sessions.revokeAliveOf.mockResolvedValue(false);
|
||||
expect((await failureOf(service.end(ARA, OLDER, 'cookie')))?.code).toBe(
|
||||
expect((await failureOf(service.end(USER, OLDER, 'cookie')))?.code).toBe(
|
||||
'RES_001',
|
||||
);
|
||||
});
|
||||
|
||||
it('다른 곳 모두 끊기 — 지금 세션만 남긴다, 지금 세션을 모르면 모두', async () => {
|
||||
await expect(service.endOthers(ARA, 'cookie')).resolves.toBe(2);
|
||||
await expect(service.endOthers(USER, 'cookie')).resolves.toBe(2);
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
@@ -158,8 +163,8 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
CURRENT,
|
||||
);
|
||||
|
||||
auth.currentSessionId.mockResolvedValue(null);
|
||||
await service.endOthers(ARA, undefined);
|
||||
auth.requestSessionId.mockResolvedValue(null);
|
||||
await service.endOthers(USER, undefined);
|
||||
expect(sessions.revokeAllOf).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
@@ -167,4 +172,70 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('앱(Bearer)이 부르면 그 앱 세션(액세스 토큰의 sid)이 지금 세션이다 — 맨 앞 · isCurrent, 기기는 ARA SYSTEM 앱 · Android (계약 1절 "앱 로그인")', async () => {
|
||||
const appUser = { ...USER, sessionId: OTHER, client: 'app' as const };
|
||||
auth.requestSessionId.mockResolvedValue(OTHER);
|
||||
sessions.listAliveOf.mockResolvedValue([
|
||||
session(CURRENT, '2026-10-31T05:00:00.000Z'),
|
||||
session(OTHER, '2026-10-30T01:00:00.000Z', {
|
||||
userAgent: APP_UA,
|
||||
client: 'app',
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await service.list(appUser, undefined, { limit: 20 });
|
||||
|
||||
expect(auth.requestSessionId).toHaveBeenCalledWith(appUser, undefined);
|
||||
expect(page.items[0]).toMatchObject({
|
||||
id: OTHER,
|
||||
isCurrent: true,
|
||||
client: 'app',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
|
||||
});
|
||||
expect(page.items[1]).toMatchObject({
|
||||
id: CURRENT,
|
||||
isCurrent: false,
|
||||
client: 'web',
|
||||
});
|
||||
});
|
||||
|
||||
it('앱 이름은 세션 표가 앱일 때만 — 웹 세션이 앱 꼴 User-Agent 를 보내도 브라우저는 null, 앱 세션은 User-Agent 가 없어도 ARA SYSTEM 앱', async () => {
|
||||
sessions.listAliveOf.mockResolvedValue([
|
||||
session(CURRENT, '2026-10-31T05:00:00.000Z', { userAgent: APP_UA }),
|
||||
session(OTHER, '2026-10-30T01:00:00.000Z', {
|
||||
userAgent: null,
|
||||
client: 'app',
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await service.list(USER, 'refresh.cookie', { limit: 20 });
|
||||
|
||||
expect(page.items[0]).toMatchObject({
|
||||
id: CURRENT,
|
||||
client: 'web',
|
||||
device: { browser: null, os: 'Android' },
|
||||
});
|
||||
expect(page.items[1]).toMatchObject({
|
||||
id: OTHER,
|
||||
client: 'app',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('앱이 부른 다른 곳 모두 끊기는 그 앱 세션을 남긴다 · 하나 끊기에서 그 앱 세션은 VAL_001', async () => {
|
||||
const appUser = { ...USER, sessionId: OTHER, client: 'app' as const };
|
||||
auth.requestSessionId.mockResolvedValue(OTHER);
|
||||
|
||||
await service.endOthers(appUser, undefined);
|
||||
expect(sessions.revokeAllOf).toHaveBeenLastCalledWith(
|
||||
ARA,
|
||||
'ended-by-user',
|
||||
expect.any(Date),
|
||||
OTHER,
|
||||
);
|
||||
expect(
|
||||
(await failureOf(service.end(appUser, OTHER, undefined)))?.code,
|
||||
).toBe('VAL_001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
AuthenticatedUser,
|
||||
SessionClient,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import {
|
||||
type PaginatedResult,
|
||||
toPaginatedResult,
|
||||
@@ -10,17 +14,19 @@ import {
|
||||
decodeOffsetCursor,
|
||||
encodeOffsetCursor,
|
||||
} from '@/shared/utils/offset-cursor';
|
||||
import { describeUserAgent, type DeviceNames } from '@/shared/utils/user-agent';
|
||||
import {
|
||||
describeAppUserAgent,
|
||||
describeUserAgent,
|
||||
type DeviceNames,
|
||||
} from '@/shared/utils/user-agent';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import { SESSION_ID_PATTERN } from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import { normalizeEmail } from './employee-address';
|
||||
|
||||
/** 커서의 범위 — 이 목록의 커서만 받는다 */
|
||||
const SESSION_LIST_SCOPE = 'auth-sessions';
|
||||
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 없는 세션과 같다) */
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */
|
||||
export interface AuthSessionView {
|
||||
@@ -30,7 +36,9 @@ export interface AuthSessionView {
|
||||
readonly lastActiveAt: Date;
|
||||
readonly ip: string | null;
|
||||
readonly device: DeviceNames | null;
|
||||
/** 이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 */
|
||||
/** 세션을 연 클라이언트 — 웹(브라우저) · 앱 (세션 표의 client — 계약 1절 "앱 로그인") */
|
||||
readonly client: SessionClient;
|
||||
/** 이 요청의 세션인가 — 웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것 */
|
||||
readonly isCurrent: boolean;
|
||||
}
|
||||
|
||||
@@ -39,9 +47,20 @@ function lastActiveAt(session: AuthSessionEntity): Date {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 세션의 목록 · 끊기.
|
||||
* 세션의 기기 이름 — 앱 이름(ARA SYSTEM 앱)은 세션 표가 앱이라고 할 때만 붙인다.
|
||||
* User-Agent 는 꾸밀 수 있으므로 웹 세션이 앱 꼴을 보내도 앱처럼 보이지 않게 (브라우저 null)
|
||||
*/
|
||||
function deviceOf(session: AuthSessionEntity): DeviceNames | null {
|
||||
return session.client === 'app'
|
||||
? describeAppUserAgent(session.userAgent)
|
||||
: describeUserAgent(session.userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 · 앱 세션의 목록 · 끊기.
|
||||
*
|
||||
* - 지금 세션은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 안다 — 없거나 남의 것이면 isCurrent 는 모두 false,
|
||||
* - 지금 세션은 웹은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다), 앱(Bearer)은 액세스 토큰의 sid 로 안다
|
||||
* (AuthService.requestSessionId — 계약 1절 "앱 로그인 › 지금 세션"). 모르면 isCurrent 는 모두 false,
|
||||
* "다른 곳 모두 끊기"는 모두 끊는다(비밀번호 바꾸기와 같다)
|
||||
* - 끊은 세션은 바로 막힌다 — 세션 표의 끊기가 끊은 세션 기억에 넣고 가드가 그 액세스 토큰을 막는다 (auth-session.repository.ts)
|
||||
* - 목록은 지금 세션이 맨 앞, 그다음 최근 활동 차례 — 위치 커서(shared/utils/offset-cursor.ts), 기본 20 · 최대 100
|
||||
@@ -58,15 +77,15 @@ export class AuthSessionsService {
|
||||
|
||||
/** GET /api/auth/sessions */
|
||||
async list(
|
||||
user: string,
|
||||
user: AuthenticatedUser,
|
||||
refreshToken: string | undefined,
|
||||
query: { readonly cursor?: string; readonly limit: number },
|
||||
now: Date = new Date(),
|
||||
): Promise<PaginatedResult<AuthSessionView>> {
|
||||
const address = normalizeEmail(user);
|
||||
const address = normalizeEmail(user.email);
|
||||
const start = this.offsetOf(query.cursor);
|
||||
const [currentId, rows] = await Promise.all([
|
||||
this.auth.currentSessionId(refreshToken, address),
|
||||
this.auth.requestSessionId(user, refreshToken),
|
||||
this.sessions.listAliveOf(address, now),
|
||||
]);
|
||||
|
||||
@@ -87,7 +106,8 @@ export class AuthSessionsService {
|
||||
createdAt: session.createdAt,
|
||||
lastActiveAt: lastActiveAt(session),
|
||||
ip: session.ip,
|
||||
device: describeUserAgent(session.userAgent),
|
||||
device: deviceOf(session),
|
||||
client: session.client,
|
||||
isCurrent: session.id === currentId,
|
||||
})),
|
||||
{
|
||||
@@ -104,13 +124,13 @@ export class AuthSessionsService {
|
||||
* DELETE /api/auth/sessions/{id} — 지금 세션은 400 VAL_001 (로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001
|
||||
*/
|
||||
async end(
|
||||
user: string,
|
||||
user: AuthenticatedUser,
|
||||
sessionId: string,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<void> {
|
||||
const address = normalizeEmail(user);
|
||||
const address = normalizeEmail(user.email);
|
||||
if (!SESSION_ID_PATTERN.test(sessionId)) throw this.sessionNotFound();
|
||||
const currentId = await this.auth.currentSessionId(refreshToken, address);
|
||||
const currentId = await this.auth.requestSessionId(user, refreshToken);
|
||||
if (sessionId.toLowerCase() === currentId?.toLowerCase()) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: 'cannot-end-current-session', sid: sessionId },
|
||||
@@ -128,13 +148,13 @@ export class AuthSessionsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 */
|
||||
/** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 (앱이 부르면 그 앱 세션을 남긴다) */
|
||||
async endOthers(
|
||||
user: string,
|
||||
user: AuthenticatedUser,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<number> {
|
||||
const address = normalizeEmail(user);
|
||||
const currentId = await this.auth.currentSessionId(refreshToken, address);
|
||||
const address = normalizeEmail(user.email);
|
||||
const currentId = await this.auth.requestSessionId(user, refreshToken);
|
||||
const ended = await this.sessions.revokeAllOf(
|
||||
address,
|
||||
'ended-by-user',
|
||||
|
||||
@@ -3,9 +3,13 @@ import type { SessionRestriction } from '@/common/decorators/allow-restricted-se
|
||||
/**
|
||||
* JWT 페이로드.
|
||||
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
|
||||
* `cli` 로 웹 · 앱 토큰을 구분한다 — 앱 토큰은 Bearer · 본문으로만, 웹 토큰은 쿠키로만 받는다 (계약 1절 "앱 로그인")
|
||||
*/
|
||||
export type AuthTokenType = 'access' | 'refresh';
|
||||
|
||||
/** 앱 토큰의 표시 (`cli`) — 앱 로그인 · 앱 갱신이 준 두 토큰에만 싣는다. 웹(쿠키) 토큰에는 없다 */
|
||||
export const APP_TOKEN_CLIENT = 'app';
|
||||
|
||||
export interface AuthTokenPayload {
|
||||
/** 직원 메일 주소 */
|
||||
readonly sub: string;
|
||||
@@ -18,6 +22,11 @@ export interface AuthTokenPayload {
|
||||
* 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다
|
||||
*/
|
||||
readonly sid?: string;
|
||||
/**
|
||||
* 앱 토큰의 표시 (계약 1절 "앱 로그인") — 액세스 · 리프레시 둘 다. 있으면 앱(Bearer · 본문), 없으면 웹(쿠키) 토큰이다.
|
||||
* 가드는 Bearer 로 온 토큰에 이 표시가 없거나 쿠키로 온 토큰에 있으면 막는다(서로 바꿔 쓰지 못하게). 모르는 값이면 막는다
|
||||
*/
|
||||
readonly cli?: typeof APP_TOKEN_CLIENT;
|
||||
/**
|
||||
* 제한된 세션의 제한 (13단계 — 계약 6절 "제한된 세션") — 액세스 토큰에만, 제한이 있을 때만 싣는다.
|
||||
* 가드가 허용한 핸들러 밖이면 403 AUTH_010. 세션 표의 restriction 과 같은 값이다(로그인 · 갱신 때 정한다)
|
||||
|
||||
@@ -46,6 +46,13 @@ export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
|
||||
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
|
||||
export const JWT_ALGORITHM = 'HS256';
|
||||
|
||||
/**
|
||||
* 세션 id 모양 (auth_sessions.id — UUID) — DB 의 uuid 열에 넣기 전 · 토큰의 sid 를 믿기 전에 본다
|
||||
* (모양이 틀리면 DB 오류가 SYS_001 로 새어 나가거나 없는 세션을 지금 세션으로 알게 된다)
|
||||
*/
|
||||
export const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
|
||||
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
|
||||
@@ -85,6 +92,30 @@ export const REVOKED_SESSION_MEMORY_MS = ACCESS_TTL_MAX_SECONDS * 1000;
|
||||
export const LOGIN_REQUEST_LIMIT = 60;
|
||||
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 로그인 요청 수 상한의 칸 이름 — 웹 로그인과 앱 로그인(계약 1절 "앱 로그인")이 같은 IP 의 칸 **하나**를 함께 쓴다.
|
||||
* throttler 는 라우트마다 따로 세므로 그대로면 앱 경로가 한도를 하나 더 얻는다 — 키를 같은 이름으로 만들어 막는다
|
||||
*/
|
||||
export const LOGIN_THROTTLE_KEY = 'auth-login';
|
||||
|
||||
/**
|
||||
* 웹 · 앱 로그인의 `@Throttle` 설정 (IP 당 1분 60회 — 함께 센다). tracker 는 전역 가드의 clientIpKey (IPv6 /64)
|
||||
*/
|
||||
export const LOGIN_THROTTLE = {
|
||||
default: {
|
||||
ttl: LOGIN_REQUEST_WINDOW_MS,
|
||||
limit: LOGIN_REQUEST_LIMIT,
|
||||
generateKey: (_context: unknown, tracker: string, name: string): string =>
|
||||
`${LOGIN_THROTTLE_KEY}:${name}:${tracker}`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 리프레시 토큰의 길이 상한 — 넘으면 서명을 보기 전에 틀린 토큰으로 본다(갱신 401 AUTH_001 · 앱 로그아웃은 Bearer 의 sid 로 넘어감 — 계약 1절 "앱 로그인").
|
||||
* 서버가 만든 토큰은 500자 안팎이다 — 아주 긴 값으로 서명 확인을 붙잡지 못하게 넉넉히 둔다
|
||||
*/
|
||||
export const MAX_REFRESH_TOKEN_LENGTH = 2_048;
|
||||
|
||||
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
|
||||
export const MAX_PASSWORD_LENGTH = 256;
|
||||
|
||||
|
||||
@@ -29,11 +29,7 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
|
||||
import {
|
||||
LOGIN_REQUEST_LIMIT,
|
||||
LOGIN_REQUEST_WINDOW_MS,
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
} from './auth.constants';
|
||||
import { LOGIN_THROTTLE, REFRESH_TOKEN_COOKIE } from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthUserDto } from './dto/auth-user.dto';
|
||||
@@ -42,8 +38,9 @@ import { LoginResultDto } from './dto/login-result.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
|
||||
/**
|
||||
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
|
||||
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다. 앱(모바일)의 토큰 경로는 app-auth.controller.ts (`/api/auth/app/*`).
|
||||
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
|
||||
* me · 비밀번호 바꾸기는 앱도 Bearer 로 부른다(가드가 두 길을 받는다 — 앱은 "지금 세션"을 액세스 토큰의 sid 로 안다).
|
||||
* 13단계: `/api/auth/*` 는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다 — 로그인 · me · 갱신의 응답에 restriction · twoFactorDeadline 이 있다
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@@ -56,11 +53,10 @@ export class AuthController {
|
||||
) {}
|
||||
|
||||
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
|
||||
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
|
||||
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회).
|
||||
// 앱 로그인(app-auth.controller.ts)과 같은 칸을 함께 센다 — 앱 경로로 한도를 비켜 가지 못하게
|
||||
@Post('login')
|
||||
@Throttle({
|
||||
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
|
||||
})
|
||||
@Throttle(LOGIN_THROTTLE)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
|
||||
@@ -189,7 +185,8 @@ export class AuthController {
|
||||
|
||||
/**
|
||||
* 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아
|
||||
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
|
||||
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다 — 앱(Bearer)은 쿠키가 없어 액세스 토큰의 sid 로 안다(계약 1절 "앱 로그인").
|
||||
* 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
|
||||
* 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다.
|
||||
*/
|
||||
@Put('password')
|
||||
@@ -201,6 +198,7 @@ export class AuthController {
|
||||
description:
|
||||
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
|
||||
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
|
||||
'이 요청의 세션은 웹은 리프레시 쿠키, 앱(Bearer)은 액세스 토큰의 sid 로 안다. ' +
|
||||
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
|
||||
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' +
|
||||
'12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' +
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
|
||||
import { MailModule } from '@/modules/mail/mail.module';
|
||||
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
|
||||
import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
|
||||
import { AppAuthController } from './app-auth.controller';
|
||||
import { AuthSessionCleanup } from './auth-session.cleanup';
|
||||
import { AuthSessionModule } from './auth-session.module';
|
||||
import { AuthSessionsController } from './auth-sessions.controller';
|
||||
@@ -18,6 +20,7 @@ import { LoginAttemptModule } from './login-attempt.module';
|
||||
|
||||
/**
|
||||
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
|
||||
* 웹은 쿠키(AuthController), 앱(모바일)은 토큰 본문 · Bearer(AppAuthController — 계약 1절 "앱 로그인") — 세션 규칙은 AuthService 하나다.
|
||||
*
|
||||
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
|
||||
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
|
||||
@@ -38,8 +41,10 @@ import { LoginAttemptModule } from './login-attempt.module';
|
||||
MailDirectoryModule,
|
||||
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
|
||||
AccountSecurityModule,
|
||||
// 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다)
|
||||
MailSearchModule,
|
||||
],
|
||||
controllers: [AuthController, AuthSessionsController],
|
||||
controllers: [AuthController, AppAuthController, AuthSessionsController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH_SETTINGS,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import type { CredentialCheck } from '@/modules/mail/mail.types';
|
||||
import type { MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import type { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import { hashRefreshToken } from './auth-session.policy';
|
||||
import type {
|
||||
@@ -25,6 +26,7 @@ import type { AuthTokenPayload } from './auth-token.types';
|
||||
import {
|
||||
LOGIN_ADDRESS_FAILURE_LIMIT,
|
||||
LOGIN_IP_FAILURE_LIMIT,
|
||||
MAX_REFRESH_TOKEN_LENGTH,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
} from './auth.constants';
|
||||
import { AuthService } from './auth.service';
|
||||
@@ -252,13 +254,15 @@ describe('AuthService', () => {
|
||||
let directory: { recordLogin: jest.Mock };
|
||||
let codes: TwoFactorCodeGuard;
|
||||
let twoFactorLocks: TwoFactorLocks;
|
||||
let loginEvents: { recordWebmail: jest.Mock };
|
||||
let loginEvents: { recordSignIn: jest.Mock };
|
||||
let appPasswords: { removeAllAfterPasswordChange: jest.Mock };
|
||||
/** 2단계 인증 필수 정책 대역 (13단계) — 처음은 제한 · 유예 없음 */
|
||||
let twoFactorPolicy: {
|
||||
sessionState: jest.Mock;
|
||||
isEnabledInEngine: jest.Mock;
|
||||
};
|
||||
/** 검색 색인 대역 (14단계) — 로그인한 직원의 상태 줄 */
|
||||
let searchIndex: { enrollAfterLogin: jest.Mock };
|
||||
let jwt: JwtService;
|
||||
let service: AuthService;
|
||||
let logs: string[];
|
||||
@@ -277,6 +281,7 @@ describe('AuthService', () => {
|
||||
loginEvents as unknown as LoginEventsService,
|
||||
appPasswords as unknown as AppPasswordsService,
|
||||
twoFactorPolicy as unknown as TwoFactorPolicyService,
|
||||
searchIndex as unknown as MailSearchIndexService,
|
||||
);
|
||||
|
||||
const decode = (token: string) =>
|
||||
@@ -319,7 +324,7 @@ describe('AuthService', () => {
|
||||
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
codes = new TwoFactorCodeGuard(attempts);
|
||||
twoFactorLocks = new TwoFactorLocks();
|
||||
loginEvents = { recordWebmail: jest.fn().mockResolvedValue(undefined) };
|
||||
loginEvents = { recordSignIn: jest.fn().mockResolvedValue(undefined) };
|
||||
appPasswords = {
|
||||
removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
@@ -329,6 +334,7 @@ describe('AuthService', () => {
|
||||
.mockResolvedValue({ restriction: null, deadline: null }),
|
||||
isEnabledInEngine: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
searchIndex = { enrollAfterLogin: jest.fn().mockResolvedValue(undefined) };
|
||||
jwt = new JwtService({});
|
||||
service = build();
|
||||
|
||||
@@ -395,6 +401,22 @@ describe('AuthService', () => {
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('로그인에 성공한 직원의 검색 색인 상태 줄을 만든다 (14단계) — 실패한 로그인 · 코드를 기다리는 로그인은 만들지 않는다', async () => {
|
||||
await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
|
||||
|
||||
searchIndex.enrollAfterLogin.mockClear();
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', CLIENT_IP)))?.code,
|
||||
).toBe('AUTH_004');
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
expect((await service.login(ARA, PASSWORD, CLIENT_IP)).status).toBe(
|
||||
'twoFactorRequired',
|
||||
);
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('사내 목록 기록에 실패해도 로그인은 성공한다 — 경고만 남긴다 (주소는 가려서)', async () => {
|
||||
directory.recordLogin.mockRejectedValue(new Error('db down'));
|
||||
|
||||
@@ -914,6 +936,20 @@ describe('AuthService', () => {
|
||||
await expect(rotateFrom(colleague)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('비밀번호가 바뀌면 검색 색인의 멈춤을 풀고 줄을 만든다 (14단계 — 만료로 멈춘 색인) · 바뀌지 않았으면 하지 않는다', async () => {
|
||||
const current = await loginToken();
|
||||
searchIndex.enrollAfterLogin.mockClear();
|
||||
|
||||
mail.changePassword.mockResolvedValueOnce('new-password-rejected');
|
||||
expect((await failureOf(change(PASSWORD, current)))?.code).toBe(
|
||||
'VAL_001',
|
||||
);
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
|
||||
await expect(change(PASSWORD, current)).resolves.toBeUndefined();
|
||||
expect(searchIndex.enrollAfterLogin).toHaveBeenCalledWith(ARA);
|
||||
});
|
||||
|
||||
it('리프레시 쿠키가 없거나 · 위조거나 · 다른 직원의 것이면 이 세션을 알 수 없어 그 직원의 세션을 모두 끊는다', async () => {
|
||||
const colleague = (await service.login(DOYUN, PASSWORD, CLIENT_IP))
|
||||
.tokens!.refreshToken;
|
||||
@@ -1249,7 +1285,7 @@ describe('AuthService', () => {
|
||||
expect(sessions.create).not.toHaveBeenCalled();
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
// 남기는 것은 판정이 끝난 시도뿐이다 (성공 · AUTH_004 · AUTH_006)
|
||||
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordSignIn).not.toHaveBeenCalled();
|
||||
// 차지한 실패를 돌려줬다 — 주소 한도보다 많이 해도 막히지 않는다
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
|
||||
await expect(
|
||||
@@ -1272,7 +1308,8 @@ describe('AuthService', () => {
|
||||
CODE,
|
||||
);
|
||||
expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true });
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith({
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith({
|
||||
method: 'webmail',
|
||||
email: ARA,
|
||||
isSuccess: true,
|
||||
isTwoFactor: true,
|
||||
@@ -1298,7 +1335,7 @@ describe('AuthService', () => {
|
||||
expect(failure?.code).toBe('AUTH_006');
|
||||
expect(failure?.getStatus()).toBe(400);
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongCode',
|
||||
@@ -1419,7 +1456,7 @@ describe('AuthService', () => {
|
||||
expect(failure?.code).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordSignIn).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(
|
||||
await failureOf(
|
||||
@@ -1457,7 +1494,7 @@ describe('AuthService', () => {
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ email: ARA, failureReason: 'wrongCode' }),
|
||||
);
|
||||
|
||||
@@ -1492,7 +1529,7 @@ describe('AuthService', () => {
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
|
||||
).resolves.toMatchObject({ status: 'signedIn' });
|
||||
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isSuccess: true, isTwoFactor: false }),
|
||||
);
|
||||
});
|
||||
@@ -1505,7 +1542,7 @@ describe('AuthService', () => {
|
||||
expect(failure?.code).toBe('AUTH_004');
|
||||
expect(failure?.context).toMatchObject({ reason: 'app-password-shape' });
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
@@ -1523,13 +1560,13 @@ describe('AuthService', () => {
|
||||
it('틀린 비밀번호는 기록에 남긴다 · 사내 도메인 밖 · 막은 계정 · 한도로 막은 요청은 남기지 않는다', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await failureOf(service.login('ara.kim@gmail.com', 'wrong', CLIENT_IP));
|
||||
await failureOf(
|
||||
service.login('admin@arasystem.test', 'wrong', CLIENT_IP),
|
||||
);
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('액세스 토큰에도 세션 id(sid)를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 막는다', async () => {
|
||||
@@ -1804,8 +1841,10 @@ describe('AuthService', () => {
|
||||
expect(decode(result.tokens.accessToken).rst).toBe('passwordChange');
|
||||
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
|
||||
expect(directory.recordLogin).not.toHaveBeenCalled();
|
||||
// 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계)
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: ARA, isSuccess: true }),
|
||||
);
|
||||
});
|
||||
@@ -2069,7 +2108,7 @@ describe('AuthService', () => {
|
||||
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: '123456' }),
|
||||
).rejects.toMatchObject({ code: 'AUTH_004' });
|
||||
// 틀린 비밀번호와 같은 기록
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: ARA,
|
||||
isSuccess: false,
|
||||
@@ -2160,4 +2199,373 @@ describe('AuthService', () => {
|
||||
expect(logs.join('\n')).toContain('세션의 제한을 바꾸지 못했습니다');
|
||||
});
|
||||
});
|
||||
describe('앱 로그인 (앱 전용 토큰 경로 — 계약 1절 "앱 로그인")', () => {
|
||||
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
|
||||
const APP = { sessionClient: 'app' as const };
|
||||
|
||||
/** 앱으로 로그인해 받은 두 토큰 */
|
||||
const appLogin = async (email = ARA, ip = CLIENT_IP) => {
|
||||
const result = await service.login(email, PASSWORD, ip, {
|
||||
...APP,
|
||||
userAgent: APP_UA,
|
||||
});
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
return result.tokens;
|
||||
};
|
||||
|
||||
const refreshApp = (token: string) =>
|
||||
service.refresh(token, { ip: CLIENT_IP, ...APP });
|
||||
|
||||
it('같은 로그인 함수로 앱 세션을 연다 — 세션 표 client app, 두 토큰에 표시 cli: app, 끝나는 때는 토큰의 exp 와 같다', async () => {
|
||||
const tokens = await appLogin();
|
||||
|
||||
const row = sessions.only();
|
||||
expect(row.client).toBe('app');
|
||||
expect(row.userAgent).toBe(APP_UA);
|
||||
const access = decode(tokens.accessToken);
|
||||
const refresh = decode(tokens.refreshToken);
|
||||
expect(access).toMatchObject({ typ: 'access', cli: 'app', sid: row.id });
|
||||
expect(refresh).toMatchObject({
|
||||
typ: 'refresh',
|
||||
cli: 'app',
|
||||
sid: row.id,
|
||||
});
|
||||
expect(tokens.accessExpiresAt.getTime()).toBe(access.exp * 1000);
|
||||
expect(tokens.refreshExpiresAt.getTime()).toBe(refresh.exp * 1000);
|
||||
expect(access.exp - access.iat).toBe(settings.accessTtlSeconds);
|
||||
expect(row.tokenHash).toBe(hashRefreshToken(tokens.refreshToken));
|
||||
// 로그인 기록의 방식은 app, 로그는 가린 주소 + (앱)
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
email: ARA,
|
||||
isSuccess: true,
|
||||
userAgent: APP_UA,
|
||||
}),
|
||||
);
|
||||
expect(logs.join('\n')).toMatch(/로그인: a\*\*\*@arasystem\.test \(앱\)/);
|
||||
});
|
||||
|
||||
it('웹 로그인은 지금 그대로 — 표시가 없고 세션 표 client web', async () => {
|
||||
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
|
||||
expect(sessions.only().client).toBe('web');
|
||||
expect(decode(result.tokens.accessToken)).not.toHaveProperty('cli');
|
||||
expect(decode(result.tokens.refreshToken)).not.toHaveProperty('cli');
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'webmail' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('실패도 방식 app 으로 남기고, 실패 카운터는 웹과 함께 센다 — 주소 15분 10회를 웹 5 · 앱 5 로 채우면 둘 다 REQ_001', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(INVALID);
|
||||
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT / 2; i += 1) {
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`)))
|
||||
?.code,
|
||||
).toBe('AUTH_004');
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, 'wrong', `198.51.101.${i}`, APP)))
|
||||
?.code,
|
||||
).toBe('AUTH_004');
|
||||
}
|
||||
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
}),
|
||||
);
|
||||
|
||||
mail.verifyCredentials.mockResolvedValue(valid(ARA));
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.30', APP)))
|
||||
?.code,
|
||||
).toBe('REQ_001');
|
||||
expect(
|
||||
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.31')))?.code,
|
||||
).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('앱 비밀번호 모양 · 막는 계정은 앱 경로에서도 엔진에 묻지 않고 AUTH_004', async () => {
|
||||
const appPassword = `appx${'a'.repeat(36)}`;
|
||||
for (const [email, password] of [
|
||||
[ARA, appPassword],
|
||||
['admin@arasystem.test', PASSWORD],
|
||||
] as const) {
|
||||
expect(
|
||||
(await failureOf(service.login(email, password, CLIENT_IP, APP)))
|
||||
?.code,
|
||||
).toBe('AUTH_004');
|
||||
}
|
||||
expect(mail.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('2단계 인증을 켠 직원은 코드 단계를 거친다 — 코드 전에는 토큰 없음(세션 없음), 코드 뒤에 앱 세션', async () => {
|
||||
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
|
||||
|
||||
const first = await service.login(ARA, PASSWORD, CLIENT_IP, APP);
|
||||
expect(first).toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
user: null,
|
||||
tokens: null,
|
||||
});
|
||||
expect(sessions.rows.size).toBe(0);
|
||||
|
||||
const second = await service.login(ARA, PASSWORD, CLIENT_IP, {
|
||||
...APP,
|
||||
otpCode: '123456',
|
||||
});
|
||||
expect(second.status).toBe('signedIn');
|
||||
expect(sessions.only().client).toBe('app');
|
||||
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ method: 'app', isTwoFactor: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('앱 갱신은 1회용 회전 — 새 두 토큰에도 표시가 있고, 이미 쓴 토큰(두 번 지난 것)은 세션 전체를 끊는다', async () => {
|
||||
const first = await appLogin();
|
||||
const rotated = await refreshApp(first.refreshToken);
|
||||
if (!rotated.tokens) throw new Error('회전해야 한다');
|
||||
|
||||
expect(decode(rotated.tokens.accessToken).cli).toBe('app');
|
||||
expect(decode(rotated.tokens.refreshToken).cli).toBe('app');
|
||||
expect(rotated.tokens.refreshExpiresAt.getTime()).toBe(
|
||||
decode(rotated.tokens.refreshToken).exp * 1000,
|
||||
);
|
||||
|
||||
const third = await refreshApp(rotated.tokens.refreshToken);
|
||||
if (!third.tokens) throw new Error('회전해야 한다');
|
||||
expect((await failureOf(refreshApp(first.refreshToken)))?.code).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
expect(sessions.only().revokedReason).toBe('refresh-token-reused');
|
||||
});
|
||||
|
||||
it('앱 세션의 30초 유예는 새 토큰 없이 200 으로 답하고 그 세션을 끝낸다 — 끝낸 세션의 새 토큰도 못 쓴다 (웹은 세션을 남긴다)', async () => {
|
||||
const first = await appLogin();
|
||||
const rotated = await refreshApp(first.refreshToken);
|
||||
if (!rotated.tokens) throw new Error('회전해야 한다');
|
||||
|
||||
const grace = await refreshApp(first.refreshToken);
|
||||
expect(grace.tokens).toBeNull();
|
||||
expect(grace.user).toEqual({ email: ARA, name: '김아라' });
|
||||
expect(grace.state).toEqual({
|
||||
restriction: null,
|
||||
twoFactorDeadline: null,
|
||||
});
|
||||
expect(sessions.only().revokedReason).toBe('app-refresh-grace');
|
||||
expect(
|
||||
(await failureOf(refreshApp(rotated.tokens.refreshToken)))?.context,
|
||||
).toMatchObject({ reason: 'session-revoked' });
|
||||
expect(logs.join('\n')).toMatch(/\[AUTH_001\].*앱 세션의 갱신 유예/);
|
||||
|
||||
// 웹 세션의 유예는 그대로 — 세션을 남기고 이긴 쪽의 토큰을 쓴다
|
||||
sessions.rows.clear();
|
||||
const web = await loginToken();
|
||||
const next = await rotateFrom(web);
|
||||
expect((await service.refresh(web)).tokens).toBeNull();
|
||||
expect(sessions.only().revokedAt).toBeNull();
|
||||
await expect(rotateFrom(next)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('같은 앱 토큰으로 동시에 갱신하면 하나만 회전하고, 겹친 갱신(유예)이 그 앱 세션을 끝낸다', async () => {
|
||||
const first = await appLogin();
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 3 }, () => refreshApp(first.refreshToken)),
|
||||
);
|
||||
|
||||
expect(results.filter((result) => result.tokens !== null)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(sessions.only().revokedReason).toBe('app-refresh-grace');
|
||||
});
|
||||
|
||||
it('앱 유예의 세션 끝내기가 실패하면 유예로 답하지 않고 던진다 — 세션을 남긴 채 200 을 주지 않는다', async () => {
|
||||
const first = await appLogin();
|
||||
await refreshApp(first.refreshToken);
|
||||
sessions.revoke.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(refreshApp(first.refreshToken)).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('빈 글 · 길이 상한을 넘는 리프레시 토큰은 서명을 보기 전에 AUTH_001 (웹 · 앱 같다)', async () => {
|
||||
const long = 'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1);
|
||||
for (const failure of [
|
||||
await failureOf(refreshApp(long)),
|
||||
await failureOf(service.refresh(long)),
|
||||
]) {
|
||||
expect(failure?.code).toBe('AUTH_001');
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'refresh-token-too-long',
|
||||
});
|
||||
}
|
||||
expect((await failureOf(refreshApp('')))?.context).toMatchObject({
|
||||
reason: 'refresh-token-missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('웹 토큰은 앱 갱신에서 · 앱 토큰은 웹 갱신에서 받지 않는다 — AUTH_001 이고 세션은 끊지 않는다', async () => {
|
||||
const app = await appLogin();
|
||||
const web = await loginToken();
|
||||
|
||||
for (const failure of [
|
||||
await failureOf(service.refresh(app.refreshToken)),
|
||||
await failureOf(refreshApp(web)),
|
||||
]) {
|
||||
expect(failure?.code).toBe('AUTH_001');
|
||||
expect(failure?.context).toMatchObject({
|
||||
reason: 'refresh-token-client-mismatch',
|
||||
});
|
||||
}
|
||||
expect(sessions.revoke).not.toHaveBeenCalled();
|
||||
// 제 길로는 그대로 회전한다
|
||||
await expect(refreshApp(app.refreshToken)).resolves.toMatchObject({
|
||||
tokens: expect.anything() as unknown,
|
||||
});
|
||||
await expect(rotateFrom(web)).resolves.toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('세션 표의 client 가 토큰과 다르면 없는 세션처럼 AUTH_001 — 재사용으로 끊지 않는다', async () => {
|
||||
const app = await appLogin();
|
||||
sessions.only().client = 'web';
|
||||
|
||||
const failure = await failureOf(refreshApp(app.refreshToken));
|
||||
expect(failure?.code).toBe('AUTH_001');
|
||||
expect(failure?.context).toMatchObject({ reason: 'session-missing' });
|
||||
expect(sessions.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('앱 로그아웃 (logoutApp)', () => {
|
||||
it('리프레시 토큰으로 세션을 끝낸다 — 그 뒤 갱신은 AUTH_001', async () => {
|
||||
const tokens = await appLogin();
|
||||
|
||||
await service.logoutApp(tokens.refreshToken, undefined);
|
||||
|
||||
expect(sessions.only().revokedReason).toBe('logout');
|
||||
expect((await failureOf(refreshApp(tokens.refreshToken)))?.code).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
expect(logs.join('\n')).toMatch(
|
||||
/로그아웃: a\*\*\*@arasystem\.test \(앱\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('리프레시 토큰이 없거나 틀리면(빈 글 · 길이 상한 넘음 · 위조 · 웹 토큰) 액세스 토큰의 sid 로 끝낸다 — 만료된 액세스 토큰도 서명이 맞으면', async () => {
|
||||
const web = await loginToken();
|
||||
for (const refreshToken of [
|
||||
undefined,
|
||||
'',
|
||||
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
|
||||
'garbage',
|
||||
web,
|
||||
]) {
|
||||
const tokens = await appLogin();
|
||||
const { sid } = decode(tokens.accessToken);
|
||||
await service.logoutApp(refreshToken, tokens.accessToken);
|
||||
expect(sessions.rows.get(sid!)?.revokedReason).toBe('logout');
|
||||
}
|
||||
// 웹 세션은 그대로다 (웹 토큰은 앱 로그아웃에서 쓰지 않는다)
|
||||
expect(sessions.rows.get(decode(web).sid!)?.revokedAt).toBeNull();
|
||||
|
||||
const tokens = await appLogin();
|
||||
const { sid } = decode(tokens.accessToken);
|
||||
const expiredAccess = sign(
|
||||
{ sub: ARA, typ: 'access', name: '김아라', sid, cli: 'app' },
|
||||
-60,
|
||||
);
|
||||
await service.logoutApp(undefined, expiredAccess);
|
||||
expect(sessions.rows.get(sid!)?.revokedReason).toBe('logout');
|
||||
});
|
||||
|
||||
it('어느 쪽도 맞지 않으면 아무것도 끝내지 않고 성공한다 — 없음 · 위조 · 웹 액세스 토큰 · 리프레시 토큰을 Bearer 에', async () => {
|
||||
const web = await service.login(ARA, PASSWORD, CLIENT_IP);
|
||||
if (web.status !== 'signedIn') throw new Error('로그인해야 한다');
|
||||
const app = await appLogin();
|
||||
|
||||
for (const [refreshToken, accessToken] of [
|
||||
[undefined, undefined],
|
||||
['garbage', 'garbage'],
|
||||
[undefined, web.tokens.accessToken],
|
||||
[undefined, app.refreshToken],
|
||||
] as const) {
|
||||
await expect(
|
||||
service.logoutApp(refreshToken, accessToken),
|
||||
).resolves.toBeUndefined();
|
||||
}
|
||||
expect(sessions.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('세션 끝내기가 실패해도 던지지 않는다 — 오류 로그만', async () => {
|
||||
const tokens = await appLogin();
|
||||
sessions.revoke.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(
|
||||
service.logoutApp(tokens.refreshToken, undefined),
|
||||
).resolves.toBeUndefined();
|
||||
expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('지금 세션 (requestSessionId — 앱은 sid, 웹은 리프레시 쿠키)', () => {
|
||||
it('앱 요청은 액세스 토큰의 sid · 웹 요청은 리프레시 쿠키가 가리키는 세션', async () => {
|
||||
const web = await loginToken();
|
||||
const webSid = decode(web).sid!;
|
||||
const APP_SID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
await expect(
|
||||
service.requestSessionId(
|
||||
{ email: ARA, name: '김아라', sessionId: APP_SID, client: 'app' },
|
||||
web,
|
||||
),
|
||||
).resolves.toBe(APP_SID);
|
||||
await expect(
|
||||
service.requestSessionId(
|
||||
{ email: ARA, name: '김아라', sessionId: APP_SID },
|
||||
web,
|
||||
),
|
||||
).resolves.toBe(webSid);
|
||||
await expect(
|
||||
service.requestSessionId({ email: ARA, name: '김아라' }, undefined),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('앱 리프레시 토큰은 쿠키로 와도 웹의 지금 세션이 아니다', async () => {
|
||||
const app = await appLogin();
|
||||
await expect(
|
||||
service.currentSessionId(app.refreshToken, ARA),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('앱이 비밀번호를 바꾸면 그 앱 세션(sid)만 남기고 다른 세션 · 웹 세션을 끊는다', async () => {
|
||||
const web = await loginToken();
|
||||
const app = await appLogin();
|
||||
const appSid = decode(app.accessToken).sid!;
|
||||
|
||||
await service.changePassword(
|
||||
{ email: ARA, name: '김아라', sessionId: appSid, client: 'app' },
|
||||
PASSWORD,
|
||||
'new-horse-battery-staple',
|
||||
CLIENT_IP,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
|
||||
ARA,
|
||||
'password-changed',
|
||||
expect.any(Date),
|
||||
appSid,
|
||||
);
|
||||
expect(sessions.rows.get(appSid)?.revokedAt).toBeNull();
|
||||
expect(sessions.rows.get(decode(web).sid!)?.revokedReason).toBe(
|
||||
'password-changed',
|
||||
);
|
||||
await expect(refreshApp(app.refreshToken)).resolves.toMatchObject({
|
||||
tokens: expect.anything() as unknown,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,14 +3,18 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import type {
|
||||
AuthenticatedUser,
|
||||
SessionClient,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { isAppPasswordShape } from '@/modules/account-security/app-password';
|
||||
import { AppPasswordsService } from '@/modules/account-security/app-passwords.service';
|
||||
import {
|
||||
LoginEventsService,
|
||||
type WebmailLoginAttempt,
|
||||
} from '@/modules/account-security/login-events.service';
|
||||
import type {
|
||||
LoginFailureReason,
|
||||
SignInMethod,
|
||||
} from '@/modules/account-security/login-event.entity';
|
||||
import { LoginEventsService } from '@/modules/account-security/login-events.service';
|
||||
import {
|
||||
type CodeCheck,
|
||||
TwoFactorCodeGuard,
|
||||
@@ -29,6 +33,7 @@ import type {
|
||||
PasswordChangeOutcome,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service';
|
||||
import { MailSearchIndexService } from '@/modules/mail-search/mail-search-index.service';
|
||||
import { traceTag } from '@/shared/logger/request-context';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { truncateUserAgent } from '@/shared/utils/user-agent';
|
||||
@@ -44,11 +49,13 @@ import {
|
||||
type RefreshState,
|
||||
} from './auth-session.policy';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import { APP_TOKEN_CLIENT, type AuthTokenPayload } from './auth-token.types';
|
||||
import {
|
||||
JWT_ALGORITHM,
|
||||
MAX_REFRESH_TOKEN_LENGTH,
|
||||
REFRESH_REUSE_GRACE_MS,
|
||||
SESSION_ABSOLUTE_TTL_SECONDS,
|
||||
SESSION_ID_PATTERN,
|
||||
} from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { isEmployeeAddress, normalizeEmail } from './employee-address';
|
||||
@@ -100,6 +107,11 @@ export interface ClientInfo {
|
||||
readonly ip: string;
|
||||
/** User-Agent 원문 — 없으면 null */
|
||||
readonly userAgent?: string | null;
|
||||
/**
|
||||
* 웹(쿠키) · 앱(토큰 본문 — 계약 1절 "앱 로그인") — 없으면 웹. 로그인은 그 클라이언트의 세션을 열고(로그인 기록의 방식 webmail · app),
|
||||
* 갱신은 같은 클라이언트의 리프레시 토큰만 받는다
|
||||
*/
|
||||
readonly sessionClient?: SessionClient;
|
||||
}
|
||||
|
||||
/** 로그인의 선택 값 */
|
||||
@@ -107,6 +119,8 @@ export interface LoginOptions {
|
||||
/** 2단계 인증 코드 (숫자 6자리 — 형식은 DTO 가 본다) */
|
||||
readonly otpCode?: string;
|
||||
readonly userAgent?: string | null;
|
||||
/** 웹(쿠키) · 앱(토큰 본문) — 없으면 웹. 한도 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정은 둘이 같다 */
|
||||
readonly sessionClient?: SessionClient;
|
||||
}
|
||||
|
||||
/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */
|
||||
@@ -120,11 +134,23 @@ interface LoadedProfile {
|
||||
interface RefreshClaims {
|
||||
readonly sub: string;
|
||||
readonly sid: string;
|
||||
/** 토큰의 표시(cli)로 안 클라이언트 — 표시가 없으면 웹 */
|
||||
readonly client: SessionClient;
|
||||
}
|
||||
|
||||
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
|
||||
const SESSION_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
/** 서명을 확인한 앱 액세스 토큰에서 꺼낸 값 — 앱 로그아웃이 sid 로 세션을 끝낸다 */
|
||||
interface AppAccessClaims {
|
||||
readonly sub: string;
|
||||
readonly sid: string;
|
||||
}
|
||||
|
||||
/** 클라이언트 → 로그인 기록의 방식 (계약 5절 "로그인 기록" — 웹메일 · 앱) */
|
||||
function signInMethodOf(client: ClientInfo): SignInMethod {
|
||||
return client.sessionClient === 'app' ? 'app' : 'webmail';
|
||||
}
|
||||
|
||||
/** JWT 의 시각(iat · exp)은 초 단위다 */
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
function toUser(profile: MailAccountProfile): AuthenticatedUser {
|
||||
return { email: profile.email, name: profile.name };
|
||||
@@ -183,6 +209,8 @@ export class AuthService {
|
||||
private readonly appPasswords: AppPasswordsService,
|
||||
/** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */
|
||||
private readonly twoFactorPolicy: TwoFactorPolicyService,
|
||||
/** 14단계 — 로그인한 직원의 검색 색인 상태 줄 (처음이면 만들고, 멈춰 있으면 푼다 — 던지지 않는다) */
|
||||
private readonly searchIndex: MailSearchIndexService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -202,6 +230,9 @@ export class AuthService {
|
||||
* 코드의 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다
|
||||
* 5. 틀림 → AUTH_004
|
||||
* 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006)는 로그인 기록에 남긴다 — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
*
|
||||
* 앱 로그인(`options.sessionClient: 'app'` — 계약 1절 "앱 로그인")도 이 함수 하나를 지난다 — 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 ·
|
||||
* 막는 계정이 웹과 같다(앱 경로로 한도를 비켜 가지 못하게). 다른 것은 세션의 클라이언트(app — 토큰에 표시 cli)와 로그인 기록의 방식(app)뿐이다
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
@@ -218,6 +249,7 @@ export class AuthService {
|
||||
const client: ClientInfo = {
|
||||
ip: clientIp,
|
||||
userAgent: options.userAgent ?? null,
|
||||
sessionClient: options.sessionClient ?? 'web',
|
||||
};
|
||||
|
||||
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
|
||||
@@ -300,7 +332,11 @@ export class AuthService {
|
||||
const tokens = await this.startSession(profile, client, state);
|
||||
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
|
||||
if (!passwordExpired) await this.recordInDirectory(profile);
|
||||
await this.loginEvents.recordWebmail({
|
||||
// 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다)
|
||||
if (!passwordExpired)
|
||||
await this.searchIndex.enrollAfterLogin(profile.email);
|
||||
await this.loginEvents.recordSignIn({
|
||||
method: signInMethodOf(client),
|
||||
email: profile.email,
|
||||
isSuccess: true,
|
||||
isTwoFactor,
|
||||
@@ -309,7 +345,7 @@ export class AuthService {
|
||||
userAgent: client.userAgent ?? null,
|
||||
});
|
||||
this.logger.log(
|
||||
`로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`,
|
||||
`로그인: ${maskEmail(profile.email)}${client.sessionClient === 'app' ? ' (앱)' : ''}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`,
|
||||
);
|
||||
return { status: 'signedIn', user: toUser(profile), tokens, state };
|
||||
}
|
||||
@@ -363,13 +399,14 @@ export class AuthService {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */
|
||||
/** 웹메일 · 앱 로그인 실패를 기록에 남긴다 (던지지 않는다) */
|
||||
private async recordLoginFailure(
|
||||
address: string,
|
||||
client: ClientInfo,
|
||||
failureReason: NonNullable<WebmailLoginAttempt['failureReason']>,
|
||||
failureReason: LoginFailureReason,
|
||||
): Promise<void> {
|
||||
await this.loginEvents.recordWebmail({
|
||||
await this.loginEvents.recordSignIn({
|
||||
method: signInMethodOf(client),
|
||||
email: address,
|
||||
isSuccess: false,
|
||||
isTwoFactor: false,
|
||||
@@ -399,6 +436,7 @@ export class AuthService {
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
* 앱(`client.sessionClient: 'app'`)은 본문의 리프레시 토큰으로 같은 규칙을 탄다 (계약 1절 "앱 로그인") — 웹 토큰은 앱 갱신에서, 앱 토큰은 웹 갱신에서 받지 않는다(AUTH_001).
|
||||
*
|
||||
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
|
||||
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
|
||||
@@ -415,7 +453,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const claims = await this.verifyRefreshToken(refreshToken, false);
|
||||
const claims = await this.verifyRefreshToken(
|
||||
refreshToken,
|
||||
false,
|
||||
client.sessionClient ?? 'web',
|
||||
);
|
||||
const presentedHash = hashRefreshToken(refreshToken);
|
||||
const now = new Date();
|
||||
const session = await this.sessions.findById(claims.sid);
|
||||
@@ -446,7 +488,8 @@ export class AuthService {
|
||||
* 2단계 인증은 그대로 남는다
|
||||
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
|
||||
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊고(끊은 세션의 액세스 토큰은 가드가 바로 막는다) **앱 비밀번호를 모두 지운다** —
|
||||
* 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. 쿠키로 이 세션을 알 수 없으면 모두 끊는다.
|
||||
* 이 요청의 세션만 남긴다(requestSessionId — 웹은 리프레시 쿠키(Path=/api/auth 라 함께 온다), 앱(Bearer)은 액세스 토큰의 sid).
|
||||
* 이 세션을 알 수 없으면 모두 끊는다.
|
||||
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 둘 다 해 두고 503 MAIL_001 로 답한다. 끊기 · 지우기에 실패해도 던지지 않는다(error 로그).
|
||||
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
|
||||
* 비밀번호 · 코드는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 · 지운 앱 비밀번호 수만.
|
||||
@@ -536,24 +579,31 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.settlePasswordChange(outcome, ticket, address, refreshToken);
|
||||
await this.settlePasswordChange(
|
||||
outcome,
|
||||
ticket,
|
||||
address,
|
||||
await this.requestSessionId(user, refreshToken),
|
||||
);
|
||||
await this.advancePasswordChangeRestriction(
|
||||
user,
|
||||
address,
|
||||
refreshToken,
|
||||
twoFactorEnabled,
|
||||
);
|
||||
// 검색 색인(14단계) — 비밀번호가 바뀌었으니(만료도 풀렸다) 대리 접근이 다시 된다: 멈춘 줄을 풀고, 없던 줄은 만든다 (던지지 않는다)
|
||||
await this.searchIndex.enrollAfterLogin(address);
|
||||
} finally {
|
||||
releaseLock?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기) */
|
||||
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기). keepSessionId — 남길 이 요청의 세션(모르면 null — 모두 끊는다) */
|
||||
private async settlePasswordChange(
|
||||
outcome: PasswordChangeOutcome,
|
||||
ticket: LoginAttemptTicket,
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
keepSessionId: string | null,
|
||||
): Promise<void> {
|
||||
switch (outcome) {
|
||||
case 'current-password-rejected':
|
||||
@@ -598,7 +648,7 @@ export class AuthService {
|
||||
ticket,
|
||||
'abort',
|
||||
address,
|
||||
refreshToken,
|
||||
keepSessionId,
|
||||
'password-change-uncertain',
|
||||
);
|
||||
throw new BusinessException('MAIL_001', {
|
||||
@@ -615,7 +665,7 @@ export class AuthService {
|
||||
ticket,
|
||||
'succeed',
|
||||
address,
|
||||
refreshToken,
|
||||
keepSessionId,
|
||||
'password-changed',
|
||||
);
|
||||
this.logger.log(
|
||||
@@ -633,13 +683,13 @@ export class AuthService {
|
||||
ticket: LoginAttemptTicket,
|
||||
settle: 'succeed' | 'abort',
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
keepSessionId: string | null,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<{ revokedSessions: number; removedAppPasswords: number }> {
|
||||
try {
|
||||
const revokedSessions = await this.revokeOtherSessions(
|
||||
address,
|
||||
refreshToken,
|
||||
keepSessionId,
|
||||
reason,
|
||||
);
|
||||
const removedAppPasswords =
|
||||
@@ -703,16 +753,15 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
|
||||
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수. `keepId` 세션만 남긴다(null 이면 모두).
|
||||
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
|
||||
* AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께)
|
||||
*/
|
||||
private async revokeOtherSessions(
|
||||
address: string,
|
||||
refreshToken: string | undefined,
|
||||
keepId: string | null,
|
||||
reason: SessionRevokedReason,
|
||||
): Promise<number> {
|
||||
const keepId = await this.currentSessionId(refreshToken, address);
|
||||
try {
|
||||
return await this.sessions.revokeAllOf(
|
||||
address,
|
||||
@@ -729,8 +778,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
|
||||
* 비밀번호 바꾸기 · 로그인된 브라우저 목록(지금 세션 — 계약 5절)이 쓴다
|
||||
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰 · 앱 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
|
||||
* 웹 요청의 지금 세션이다 — 비밀번호 바꾸기 · 로그인된 브라우저 목록(계약 5절)이 requestSessionId 로 쓴다
|
||||
*/
|
||||
async currentSessionId(
|
||||
refreshToken: string | undefined,
|
||||
@@ -738,13 +787,26 @@ export class AuthService {
|
||||
): Promise<string | null> {
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
const claims = await this.verifyRefreshToken(refreshToken, true, 'web');
|
||||
return normalizeEmail(claims.sub) === address ? claims.sid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 요청의 세션 id (계약 1절 "앱 로그인 › 지금 세션" · 5절 "로그인된 브라우저") — 던지지 않는다.
|
||||
* 앱(Bearer)은 리프레시 쿠키가 없으므로 액세스 토큰의 sid 로, 웹은 지금처럼 리프레시 쿠키(Path=/api/auth)로 안다 — 모르면 null.
|
||||
* 비밀번호 바꾸기(이 세션만 남기기) · 로그인된 기기 목록(isCurrent · 다른 곳 모두 끊기)이 쓴다
|
||||
*/
|
||||
async requestSessionId(
|
||||
user: AuthenticatedUser,
|
||||
refreshToken: string | undefined,
|
||||
): Promise<string | null> {
|
||||
if (user.client === 'app') return user.sessionId ?? null;
|
||||
return this.currentSessionId(refreshToken, normalizeEmail(user.email));
|
||||
}
|
||||
|
||||
/**
|
||||
* me 의 세션 상태 (13단계 — 계약 6절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게),
|
||||
* 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다.
|
||||
@@ -776,7 +838,7 @@ export class AuthService {
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
* 토큰이 없거나 이미 쓸 수 없어도(앱 토큰 포함), 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
*/
|
||||
async logout(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
@@ -784,17 +846,47 @@ export class AuthService {
|
||||
let claims: RefreshClaims;
|
||||
try {
|
||||
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
|
||||
claims = await this.verifyRefreshToken(refreshToken, true);
|
||||
claims = await this.verifyRefreshToken(refreshToken, true, 'web');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await this.endSession(claims.sid, claims.sub, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 로그아웃 (계약 1절 "앱 로그인") — 던지지 않는다(본문 모양은 DTO 가 본다 — 글이 아닌 refreshToken · 모르는 칸은 400).
|
||||
* 본문의 리프레시 토큰으로 세션을 찾고, 없거나 틀리면(빈 글 · 길이 상한 넘음 · 서명 · 종류 · 웹 토큰) Bearer 액세스 토큰의 sid 로 끝낸다 —
|
||||
* 앱이 이어 쓸 리프레시 토큰을 잃었을 때 "로그인된 기기"에 14일 남지 않게.
|
||||
* 두 토큰 모두 만료돼도 서명이 맞으면 그 세션을 끝낸다(끝내기만 하므로 — 웹 로그아웃과 같다). 어느 쪽도 맞지 않으면 아무것도 하지 않는다
|
||||
*/
|
||||
async logoutApp(
|
||||
refreshToken: string | undefined,
|
||||
accessToken: string | undefined,
|
||||
): Promise<void> {
|
||||
// 빈 글 · 틀린 리프레시 토큰(길이 · 서명 · 종류 · 웹 토큰)은 null — 액세스 토큰의 sid 로 넘어간다
|
||||
const byRefresh = refreshToken
|
||||
? await this.verifyRefreshToken(refreshToken, true, 'app').catch(
|
||||
() => null,
|
||||
)
|
||||
: null;
|
||||
const claims =
|
||||
byRefresh ??
|
||||
(accessToken ? await this.verifyAppAccessToken(accessToken) : null);
|
||||
if (claims) await this.endSession(claims.sid, claims.sub, ' (앱)');
|
||||
}
|
||||
|
||||
/** 로그아웃의 세션 끝내기 — 실패해도 던지지 않는다(오류 로그만). 끊은 세션은 끊은 세션 기억에도 들어간다(세션 표 저장소) */
|
||||
private async endSession(
|
||||
sessionId: string,
|
||||
email: string,
|
||||
note: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.sessions.revoke(claims.sid, 'logout', new Date());
|
||||
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
|
||||
await this.sessions.revoke(sessionId, 'logout', new Date());
|
||||
this.logger.log(`로그아웃: ${maskEmail(email)}${note}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
|
||||
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (응답은 성공): ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -826,6 +918,22 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 세션의 갱신 유예 — 세션을 끝낸다 (계약 1절 "앱 로그인"). 앱은 갱신을 한 번에 하나만 하므로 유예는 "회전 응답을 잃음"이나
|
||||
* "훔친 토큰을 누가 먼저 씀" 둘뿐이고, 어느 쪽이든 앱에는 이어 쓸 리프레시 토큰이 없다 — 남겨 두면 아무도 쥐지 않은 세션이 14일 남거나
|
||||
* 먼저 쓴 쪽(공격자일 수 있다)이 재사용 감지 없이 이어 쓴다. 끝낸 세션의 액세스 토큰은 끊은 세션 기억으로 바로 막힌다.
|
||||
* 끊기가 실패하면 던진다(500) — 유예로 답하고 세션을 남기지 않게 (재사용 감지와 같다)
|
||||
*/
|
||||
private async endAppGraceSession(
|
||||
claims: RefreshClaims,
|
||||
now: Date,
|
||||
): Promise<void> {
|
||||
await this.sessions.revoke(claims.sid, 'app-refresh-grace', now);
|
||||
this.logger.warn(
|
||||
`[AUTH_001]${traceTag()} 앱 세션의 갱신 유예 — 회전 직후의 이전 토큰이 다시 와서 세션을 끝냈습니다(응답을 잃었거나 토큰이 두 번 쓰였습니다): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveRefresh(
|
||||
state: RefreshState,
|
||||
session: AuthSessionEntity | null,
|
||||
@@ -839,6 +947,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (state === 'grace') {
|
||||
// 앱 세션의 유예는 세션을 끝낸다 — 응답은 웹과 같은 200 tokens: null (계약 1절 "앱 로그인")
|
||||
if (session?.client === 'app') await this.endAppGraceSession(claims, now);
|
||||
const { profile } = await this.profileForGrace(claims.sub);
|
||||
return {
|
||||
user: toUser(profile),
|
||||
@@ -917,6 +1027,7 @@ export class AuthService {
|
||||
expiresAt,
|
||||
now,
|
||||
sessionState,
|
||||
session.client,
|
||||
);
|
||||
|
||||
const rotated = await this.sessions.rotate({
|
||||
@@ -937,6 +1048,8 @@ export class AuthService {
|
||||
const latest = await this.sessions.findById(claims.sid);
|
||||
const state = this.classify(latest, claims, presentedHash, now);
|
||||
if (state === 'grace') {
|
||||
// 같은 토큰의 두 갱신이 겹쳤다 — 앱은 한 번에 하나만 하므로 앱 세션이면 끝낸다 (위 resolveRefresh 와 같다)
|
||||
if (latest?.client === 'app') await this.endAppGraceSession(claims, now);
|
||||
return { user: toUser(profile), tokens: null, state: sessionState };
|
||||
}
|
||||
if (state === 'current') {
|
||||
@@ -1008,7 +1121,10 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록) */
|
||||
/**
|
||||
* 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록).
|
||||
* 클라이언트(웹 · 앱)는 세션 표에 남기고 토큰에도 싣는다(앱이면 표시 cli) — 갱신은 같은 클라이언트로만 된다
|
||||
*/
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
client: ClientInfo,
|
||||
@@ -1016,6 +1132,7 @@ export class AuthService {
|
||||
): Promise<SessionTokens> {
|
||||
const now = new Date();
|
||||
const id = randomUUID();
|
||||
const sessionClient = client.sessionClient ?? 'web';
|
||||
const absoluteExpiresAt = new Date(
|
||||
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
|
||||
);
|
||||
@@ -1024,7 +1141,14 @@ export class AuthService {
|
||||
this.settings.refreshTtlSeconds,
|
||||
absoluteExpiresAt,
|
||||
);
|
||||
const tokens = await this.signTokens(profile, id, expiresAt, now, state);
|
||||
const tokens = await this.signTokens(
|
||||
profile,
|
||||
id,
|
||||
expiresAt,
|
||||
now,
|
||||
state,
|
||||
sessionClient,
|
||||
);
|
||||
|
||||
await this.sessions.create({
|
||||
id,
|
||||
@@ -1040,14 +1164,27 @@ export class AuthService {
|
||||
ip: client.ip || null,
|
||||
userAgent: truncateUserAgent(client.userAgent),
|
||||
restriction: state.restriction,
|
||||
client: sessionClient,
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 토큰의 서명 · 종류 · 클라이언트를 본다 — 웹 경로(쿠키)는 표시가 없는 토큰만, 앱 경로(본문)는 앱 표시(cli)가 있는 토큰만 받는다.
|
||||
* 다르면 AUTH_001 (세션은 끊지 않는다 — 재사용이 아니다). 길이 상한을 넘으면 서명을 보기 전에 틀린 토큰이다.
|
||||
* 토큰 원문은 오류 context 에 싣지 않는다
|
||||
*/
|
||||
private async verifyRefreshToken(
|
||||
token: string,
|
||||
ignoreExpiration: boolean,
|
||||
expectedClient: SessionClient,
|
||||
): Promise<RefreshClaims> {
|
||||
if (token.length > MAX_REFRESH_TOKEN_LENGTH) {
|
||||
// 우리가 만든 토큰이 아니다 — 아주 긴 값으로 서명 확인을 붙잡지 못하게 (계약 1절 "앱 로그인": 빈 글 · 긴 글은 틀린 토큰과 같다)
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'refresh-token-too-long', length: token.length },
|
||||
});
|
||||
}
|
||||
let payload: AuthTokenPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
|
||||
@@ -1064,24 +1201,67 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const { typ, sub, sid } = payload;
|
||||
const { typ, sub, sid, cli } = payload;
|
||||
if (
|
||||
typ !== 'refresh' ||
|
||||
typeof sub !== 'string' ||
|
||||
typeof sid !== 'string' ||
|
||||
!SESSION_ID_PATTERN.test(sid)
|
||||
!SESSION_ID_PATTERN.test(sid) ||
|
||||
(cli !== undefined && cli !== APP_TOKEN_CLIENT)
|
||||
) {
|
||||
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
|
||||
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 · 모르는 표시 등
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'not-a-refresh-token' },
|
||||
});
|
||||
}
|
||||
const client: SessionClient = cli === APP_TOKEN_CLIENT ? 'app' : 'web';
|
||||
if (client !== expectedClient) {
|
||||
// 앱 토큰을 웹 갱신(쿠키)에 · 웹 토큰을 앱 갱신(본문)에 — 서로 바꿔 쓰지 못한다 (계약 1절 "앱 로그인")
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: 'refresh-token-client-mismatch',
|
||||
expected: expectedClient,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { sub, sid, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 로그아웃의 Bearer 액세스 토큰 → 세션 (서명 · 종류 · 앱 표시 · sid 모양). 만료는 따지지 않는다(끝내기만 한다).
|
||||
* 맞지 않으면 null — 앱 로그아웃은 토큰이 틀려도 200 이다(400 은 본문 모양이 틀릴 때뿐)
|
||||
*/
|
||||
private async verifyAppAccessToken(
|
||||
token: string,
|
||||
): Promise<AppAccessClaims | null> {
|
||||
let payload: AuthTokenPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
|
||||
secret: this.settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
// 만료를 보지 않는다(시간 상한도 없다) — 세션을 끝내기만 하고 권한을 주지 않으므로. 웹 logout 이 만료된 리프레시 토큰으로 끝내는 것과 같은 결
|
||||
ignoreExpiration: true,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const { typ, sub, sid, cli } = payload;
|
||||
if (
|
||||
typ !== 'access' ||
|
||||
cli !== APP_TOKEN_CLIENT ||
|
||||
typeof sub !== 'string' ||
|
||||
typeof sid !== 'string' ||
|
||||
!SESSION_ID_PATTERN.test(sid)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { sub, sid };
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
|
||||
* (세션보다 오래 사는 토큰을 만들지 않는다).
|
||||
* (세션보다 오래 사는 토큰을 만들지 않는다). 앱 세션이면 두 토큰에 앱 표시(cli)를 싣는다 (계약 1절 "앱 로그인").
|
||||
* 발급 시각(iat)을 `now` 로 정해 두어 끝나는 때(exp = iat + 수명)를 앱 응답의 accessExpiresAt · refreshExpiresAt 으로 그대로 준다
|
||||
*/
|
||||
private async signTokens(
|
||||
profile: MailAccountProfile,
|
||||
@@ -1089,12 +1269,17 @@ export class AuthService {
|
||||
refreshExpiresAt: Date,
|
||||
now: Date,
|
||||
state: SessionState,
|
||||
sessionClient: SessionClient,
|
||||
): Promise<SessionTokens> {
|
||||
const { jwtSecret, accessTtlSeconds } = this.settings;
|
||||
const refreshTtlSeconds = Math.max(
|
||||
1,
|
||||
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
|
||||
);
|
||||
const accessExpiresIn = Math.min(accessTtlSeconds, refreshTtlSeconds);
|
||||
const issuedAt = Math.floor(now.getTime() / MS_PER_SECOND);
|
||||
const clientMark: Pick<AuthTokenPayload, 'cli'> =
|
||||
sessionClient === 'app' ? { cli: APP_TOKEN_CLIENT } : {};
|
||||
// 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저")
|
||||
// 제한 · 유예(13단계)도 싣는다 — 가드가 제한을 보고 me 가 그대로 돌려준다 (있을 때만)
|
||||
const access: AuthTokenPayload = {
|
||||
@@ -1102,22 +1287,26 @@ export class AuthService {
|
||||
typ: 'access',
|
||||
name: profile.name,
|
||||
sid: sessionId,
|
||||
...clientMark,
|
||||
...(state.restriction !== null ? { rst: state.restriction } : {}),
|
||||
...(state.twoFactorDeadline !== null
|
||||
? { tfd: state.twoFactorDeadline }
|
||||
: {}),
|
||||
iat: issuedAt,
|
||||
};
|
||||
const refresh: AuthTokenPayload = {
|
||||
sub: profile.email,
|
||||
typ: 'refresh',
|
||||
sid: sessionId,
|
||||
...clientMark,
|
||||
iat: issuedAt,
|
||||
};
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwt.signAsync(access, {
|
||||
secret: jwtSecret,
|
||||
algorithm: JWT_ALGORITHM,
|
||||
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
|
||||
expiresIn: accessExpiresIn,
|
||||
jwtid: randomUUID(),
|
||||
}),
|
||||
this.jwt.signAsync(refresh, {
|
||||
@@ -1128,7 +1317,15 @@ export class AuthService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return { accessToken, refreshToken, refreshTtlSeconds };
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
refreshTtlSeconds,
|
||||
accessExpiresAt: new Date((issuedAt + accessExpiresIn) * MS_PER_SECOND),
|
||||
refreshExpiresAt: new Date(
|
||||
(issuedAt + refreshTtlSeconds) * MS_PER_SECOND,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 동시 갱신 유예의 프로필 — 기억한 세션으로 읽는다. 비밀번호가 만료된 계정은 주소의 @ 앞을 이름으로 (13단계) */
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { createValidationPipe } from '@/app.setup';
|
||||
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { AppLogoutDto, AppRefreshDto } from './app-auth.dto';
|
||||
|
||||
/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */
|
||||
async function through(
|
||||
metatype: new () => object,
|
||||
body: unknown,
|
||||
): Promise<object | 'VAL_001'> {
|
||||
try {
|
||||
return (await createValidationPipe().transform(body, {
|
||||
type: 'body',
|
||||
metatype,
|
||||
})) as object;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) return 'VAL_001';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe('앱 갱신 · 로그아웃 본문 (계약 1절 "앱 로그인" — 앱이 400 에 갇히지 않게)', () => {
|
||||
const long = 'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1);
|
||||
|
||||
it('갱신 — refreshToken 이 없거나 글이 아니거나 모르는 칸이면 400 VAL_001', async () => {
|
||||
for (const body of [
|
||||
{},
|
||||
undefined,
|
||||
{ refreshToken: null },
|
||||
{ refreshToken: 12345 },
|
||||
{ refreshToken: 'a.b.c', extra: true },
|
||||
]) {
|
||||
await expect(through(AppRefreshDto, body)).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it(`갱신 — 빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘는 글은 본문을 통과한다 (틀린 토큰 — 서비스가 서명을 보기 전에 401 AUTH_001)`, async () => {
|
||||
for (const refreshToken of ['a.b.c', '', long]) {
|
||||
await expect(through(AppRefreshDto, { refreshToken })).resolves.toEqual({
|
||||
refreshToken,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('로그아웃 — 빈 본문 · 칸 없음 · null · 빈 글 · 긴 글은 받는다(틀린 토큰은 Bearer 의 sid 로), 글이 아니거나 모르는 칸만 400 VAL_001', async () => {
|
||||
await expect(through(AppLogoutDto, {})).resolves.toEqual({});
|
||||
await expect(through(AppLogoutDto, undefined)).resolves.toEqual({});
|
||||
await expect(
|
||||
through(AppLogoutDto, { refreshToken: null }),
|
||||
).resolves.toMatchObject({});
|
||||
for (const refreshToken of ['a.b.c', '', long]) {
|
||||
await expect(through(AppLogoutDto, { refreshToken })).resolves.toEqual({
|
||||
refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
for (const body of [{ refreshToken: 12345 }, { other: 'x' }]) {
|
||||
await expect(through(AppLogoutDto, body)).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import {
|
||||
AuthUserDto,
|
||||
RESTRICTION_PROPERTY,
|
||||
TWO_FACTOR_DEADLINE_PROPERTY,
|
||||
} from './auth-user.dto';
|
||||
|
||||
/**
|
||||
* 앱 전용 토큰 경로의 본문 · 응답 (계약 1절 "앱 로그인") — 세션 규칙은 웹과 같고 토큰을 나르는 길만 다르다.
|
||||
* 토큰은 이 응답들에만 싣는다(Cache-Control: private, no-store). 로그 · 오류 context 에는 남기지 않는다.
|
||||
*/
|
||||
|
||||
/** 토큰 예시 — 진짜 토큰 모양을 문서에 두지 않는다 */
|
||||
const TOKEN_EXAMPLE = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…';
|
||||
|
||||
/**
|
||||
* 리프레시 토큰 칸은 글인지만 본다 (계약 1절 "앱 로그인" — 앱이 400 에 갇히지 않게).
|
||||
* 빈 글 · 길이 상한(MAX_REFRESH_TOKEN_LENGTH) 넘음은 본문 오류가 아니라 틀린 토큰이다 — 서비스가 서명을 보기 전에 가른다
|
||||
* (갱신 401 AUTH_001 → 앱은 세션 끝으로, 로그아웃은 Bearer 의 sid 로)
|
||||
*/
|
||||
const REFRESH_TOKEN_MESSAGE = '리프레시 토큰이 올바르지 않습니다.';
|
||||
|
||||
/** 앱이 받는 두 토큰 — 리프레시 토큰은 보안 저장소에, 액세스 토큰은 메모리에만 둔다 (앱 몫) */
|
||||
export class AppTokensDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'액세스 JWT — 보호 API 에 `Authorization: Bearer` 로 싣는다(앱 표시 cli: app — 쿠키로는 받지 않는다)',
|
||||
example: TOKEN_EXAMPLE,
|
||||
})
|
||||
accessToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'액세스 토큰이 끝나는 때 (ISO 8601 UTC — 토큰의 exp). 앱은 30초 전에 미리 갱신한다',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-11-06T03:00:00.000Z',
|
||||
})
|
||||
accessExpiresAt!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'리프레시 JWT — 1회용(갱신할 때마다 새 토큰). 이미 쓴 토큰이 다시 오면 세션 전체를 끊는다(회전 직후 30초 안의 바로 이전 토큰은 유예)',
|
||||
example: TOKEN_EXAMPLE,
|
||||
})
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'리프레시 토큰이 끝나는 때 (ISO 8601 UTC — 토큰의 exp) — 세션의 미사용 만료(14일, 로그인 후 30일을 넘지 않는다)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-11-20T02:00:00.000Z',
|
||||
})
|
||||
refreshExpiresAt!: string;
|
||||
}
|
||||
|
||||
/** 토큰 칸의 공통 Swagger 값 — signedIn · 회전에만 차고 그 밖은 null (설명은 칸마다) */
|
||||
const TOKENS_PROPERTY = {
|
||||
type: AppTokensDto,
|
||||
nullable: true,
|
||||
} as const;
|
||||
|
||||
/** POST /api/auth/app/login 의 응답 — 웹 login(LoginResultDto)과 같은 평평한 칸 + tokens */
|
||||
export class AppLoginResultDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'signedIn — 로그인했다(tokens 가 찬다) · twoFactorRequired — 2단계 인증 코드가 필요하다(tokens 는 null — 비밀번호와 코드를 함께 다시 보낸다)',
|
||||
enum: ['signedIn', 'twoFactorRequired'],
|
||||
example: 'signedIn',
|
||||
})
|
||||
status!: 'signedIn' | 'twoFactorRequired';
|
||||
|
||||
@ApiProperty({
|
||||
description: '직원 메일 주소 — twoFactorRequired 면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: 'ara.kim@arasystem.it.kr',
|
||||
})
|
||||
email!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: '표시 이름 — twoFactorRequired 면 null',
|
||||
type: String,
|
||||
nullable: true,
|
||||
example: '김아라',
|
||||
})
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
...RESTRICTION_PROPERTY,
|
||||
description: `${RESTRICTION_PROPERTY.description}. twoFactorRequired 면 null — 제한된 세션도 토큰을 받는다`,
|
||||
})
|
||||
restriction!: SessionRestriction | null;
|
||||
|
||||
@ApiProperty({
|
||||
...TWO_FACTOR_DEADLINE_PROPERTY,
|
||||
description: `${TWO_FACTOR_DEADLINE_PROPERTY.description}. twoFactorRequired 면 null`,
|
||||
})
|
||||
twoFactorDeadline!: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
...TOKENS_PROPERTY,
|
||||
description:
|
||||
'두 토큰 — signedIn(웹이 쿠키를 굽는 바로 그 경우)에만 차고, 그 밖(twoFactorRequired)은 null. 제한된 세션(restriction)도 토큰을 받는다',
|
||||
})
|
||||
tokens!: AppTokensDto | null;
|
||||
}
|
||||
|
||||
/** POST /api/auth/app/refresh 의 응답 — 웹 refresh 와 같은 칸 + tokens */
|
||||
export class AppRefreshResultDto extends AuthUserDto {
|
||||
@ApiProperty({
|
||||
...TOKENS_PROPERTY,
|
||||
description:
|
||||
'새 두 토큰(회전). 회전 직후 30초 안에 바로 이전 토큰이 다시 오면 새 토큰 없이 null — 앱은 이것을 세션 끝으로 다룬다(이어 쓸 리프레시 토큰이 없다)',
|
||||
})
|
||||
tokens!: AppTokensDto | null;
|
||||
}
|
||||
|
||||
/** POST /api/auth/app/refresh — 보안 저장소의 리프레시 토큰 */
|
||||
export class AppRefreshDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
`앱 로그인 · 앱 갱신이 준 리프레시 토큰. 없거나 글이 아니면 400 VAL_001, ` +
|
||||
`빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘음 · 틀린 토큰 · 웹(쿠키)의 토큰은 401 AUTH_001(서명을 보기 전에)`,
|
||||
example: TOKEN_EXAMPLE,
|
||||
})
|
||||
@IsString({ message: REFRESH_TOKEN_MESSAGE })
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
/** POST /api/auth/app/logout — 리프레시 토큰은 있으면 싣는다(보안 저장소를 읽지 못했으면 빈 본문 `{}`) */
|
||||
export class AppLogoutDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
`앱의 리프레시 토큰(선택) — 이것으로 세션을 찾는다. 없거나 · 빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘음 · 틀리면 ` +
|
||||
'`Authorization: Bearer` 액세스 토큰의 sid 로 끝낸다(200). 글이 아니면 400 VAL_001',
|
||||
example: TOKEN_EXAMPLE,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: REFRESH_TOKEN_MESSAGE })
|
||||
refreshToken?: string;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export class AuthSessionDto {
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'브라우저 · 운영체제의 짧은 이름 — 12단계 전에 연 세션이거나 모르면 null',
|
||||
'브라우저 · 운영체제의 짧은 이름 — 앱 세션은 브라우저가 ARA SYSTEM 앱(세션 표가 앱일 때만). 웹 세션은 12단계 전에 연 세션이거나 모르면 null',
|
||||
type: DeviceDto,
|
||||
nullable: true,
|
||||
})
|
||||
@@ -45,7 +45,15 @@ export class AuthSessionDto {
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 — 맨 앞에 온다. 쿠키가 없거나 남의 것이면 모두 false',
|
||||
'세션을 연 클라이언트 — web(웹메일 · 브라우저) · app(모바일 앱 — 계약 1절 "앱 로그인"). 앱 1단계 전에 연 세션은 web',
|
||||
enum: ['web', 'app'],
|
||||
example: 'web',
|
||||
})
|
||||
client!: 'web' | 'app';
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 요청의 세션(웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것)인가 — 맨 앞에 온다. 웹 요청에 쿠키가 없거나 남의 것이면 모두 false',
|
||||
example: true,
|
||||
})
|
||||
isCurrent!: boolean;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { AccessTokenRequest } from './access-token';
|
||||
import type { AuthSessionEntity } from './auth-session.entity';
|
||||
import type { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import type { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
@@ -31,6 +33,22 @@ const strategy = new JwtStrategy(
|
||||
|
||||
const SID = '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a';
|
||||
|
||||
/** 웹 요청 — 토큰이 액세스 쿠키로 왔다 (서명 확인은 passport-jwt 몫이라 값은 상관없다) */
|
||||
const COOKIE_REQUEST: AccessTokenRequest = {
|
||||
headers: {},
|
||||
cookies: { access_token: 'signed.cookie.token' },
|
||||
};
|
||||
/** 앱 요청 — 토큰이 Authorization: Bearer 로 왔다 */
|
||||
const BEARER_REQUEST: AccessTokenRequest = {
|
||||
headers: { authorization: 'Bearer signed.bearer.token' },
|
||||
cookies: {},
|
||||
};
|
||||
|
||||
const validateFromCookie = (payload: AuthTokenPayload) =>
|
||||
strategy.validate(COOKIE_REQUEST, payload);
|
||||
const validateFromBearer = (payload: AuthTokenPayload) =>
|
||||
strategy.validate(BEARER_REQUEST, payload);
|
||||
|
||||
async function codeOf(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<string | undefined> {
|
||||
@@ -51,7 +69,7 @@ describe('JwtStrategy.validate', () => {
|
||||
|
||||
it('액세스 토큰이면 직원 정보를 돌려준다', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -60,7 +78,7 @@ describe('JwtStrategy.validate', () => {
|
||||
});
|
||||
|
||||
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', async () => {
|
||||
const user = await strategy.validate({
|
||||
const user = await validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -76,7 +94,7 @@ describe('JwtStrategy.validate', () => {
|
||||
|
||||
it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', async () => {
|
||||
await expect(
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -95,7 +113,7 @@ describe('JwtStrategy.validate', () => {
|
||||
revokedIds.add(SID);
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -106,7 +124,7 @@ describe('JwtStrategy.validate', () => {
|
||||
// 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -116,7 +134,7 @@ describe('JwtStrategy.validate', () => {
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -128,7 +146,7 @@ describe('JwtStrategy.validate', () => {
|
||||
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', async () => {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
validateFromCookie({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
@@ -136,7 +154,7 @@ describe('JwtStrategy.validate', () => {
|
||||
it('제한이 실린 토큰은 세션 표의 지금 제한을 쓴다 · 유예의 시작 시각은 토큰 그대로 (13단계)', async () => {
|
||||
rows.set(SID, { id: SID, restriction: 'passwordChange', revokedAt: null });
|
||||
await expect(
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -155,7 +173,7 @@ describe('JwtStrategy.validate', () => {
|
||||
|
||||
it('제한을 푼 뒤의 옛 토큰은 제한 없음으로 — 세션 표가 지웠으면 null (마친 단계로 되돌아가지 않게)', async () => {
|
||||
rows.set(SID, { id: SID, restriction: null, revokedAt: null });
|
||||
const user = await strategy.validate({
|
||||
const user = await validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -169,7 +187,7 @@ describe('JwtStrategy.validate', () => {
|
||||
for (const payload of [{ sid: SID }, { sid: undefined }]) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -186,7 +204,7 @@ describe('JwtStrategy.validate', () => {
|
||||
});
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -200,7 +218,7 @@ describe('JwtStrategy.validate', () => {
|
||||
it('모르는 제한이 실린 토큰은 AUTH_001 — 제한을 모르고 통과시키지 않는다', async () => {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate({
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
@@ -209,4 +227,99 @@ describe('JwtStrategy.validate', () => {
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
it('앱 토큰(cli: app)을 Bearer 로 — 직원 정보에 client: app 을 싣는다 (지금 세션을 sid 로 안다 — 계약 1절 "앱 로그인")', async () => {
|
||||
await expect(
|
||||
validateFromBearer({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
sid: SID,
|
||||
cli: 'app',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
email: 'ara.kim@arasystem.test',
|
||||
name: '김아라',
|
||||
sessionId: SID,
|
||||
client: 'app',
|
||||
});
|
||||
});
|
||||
|
||||
it('웹 토큰(표시 없음)을 Bearer 로 · 앱 토큰을 쿠키로 보내면 AUTH_001 — 서로 바꿔 쓰지 못한다', async () => {
|
||||
const web = {
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access' as const,
|
||||
name: '김아라',
|
||||
sid: SID,
|
||||
};
|
||||
expect(await codeOf(() => validateFromBearer(web))).toBe('AUTH_001');
|
||||
expect(await codeOf(() => validateFromCookie({ ...web, cli: 'app' }))).toBe(
|
||||
'AUTH_001',
|
||||
);
|
||||
// Bearer 가 있으면 같은 요청의 쿠키는 보지 않는다 — 쿠키에 웹 토큰이 함께 와도 앱 토큰으로만 본다
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
strategy.validate(
|
||||
{
|
||||
headers: { authorization: 'bearer signed.bearer.token' },
|
||||
cookies: { access_token: 'signed.cookie.token' },
|
||||
},
|
||||
web,
|
||||
),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('모르는 표시(cli)는 어느 길로 와도 AUTH_001', async () => {
|
||||
const odd = {
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access' as const,
|
||||
name: '김아라',
|
||||
cli: 'desktop' as never,
|
||||
};
|
||||
expect(await codeOf(() => validateFromBearer(odd))).toBe('AUTH_001');
|
||||
expect(await codeOf(() => validateFromCookie(odd))).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('앱 토큰에 세션 id 가 없거나 UUID 모양이 아니면 AUTH_001 — 지금 세션 · 끊은 세션을 알 수 없다 (닫힌 쪽)', async () => {
|
||||
const app = {
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access' as const,
|
||||
name: '김아라',
|
||||
cli: 'app' as const,
|
||||
};
|
||||
for (const sid of [undefined, 'not-a-uuid', `${SID}x`, '']) {
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
validateFromBearer(sid === undefined ? app : { ...app, sid }),
|
||||
),
|
||||
).toBe('AUTH_001');
|
||||
}
|
||||
// 웹 토큰은 그대로 — sid 가 없는 12단계 전 토큰은 만료까지 쓰인다
|
||||
expect(
|
||||
await codeOf(() =>
|
||||
validateFromCookie({
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access',
|
||||
name: '김아라',
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Bearer 도 끊은 세션 · 제한(세션 표의 지금 값)을 웹과 같게 본다', async () => {
|
||||
const app = {
|
||||
sub: 'ara.kim@arasystem.test',
|
||||
typ: 'access' as const,
|
||||
name: '김아라',
|
||||
sid: SID,
|
||||
cli: 'app' as const,
|
||||
};
|
||||
rows.set(SID, { id: SID, restriction: 'twoFactorSetup', revokedAt: null });
|
||||
await expect(
|
||||
validateFromBearer({ ...app, rst: 'twoFactorSetup' }),
|
||||
).resolves.toMatchObject({ client: 'app', restriction: 'twoFactorSetup' });
|
||||
|
||||
revokedIds.add(SID);
|
||||
expect(await codeOf(() => validateFromBearer(app))).toBe('AUTH_001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Request } from 'express';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
|
||||
import { isSessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
|
||||
import { readCookie } from '@/shared/utils/cookies';
|
||||
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
|
||||
import { type AccessTokenRequest, presentedAccessToken } from './access-token';
|
||||
import { JWT_ALGORITHM, SESSION_ID_PATTERN } from './auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
|
||||
import { AuthSessionRepository } from './auth-session.repository';
|
||||
import type { AuthTokenPayload } from './auth-token.types';
|
||||
import { APP_TOKEN_CLIENT, type AuthTokenPayload } from './auth-token.types';
|
||||
import { RevokedSessions } from './revoked-sessions';
|
||||
|
||||
/** JWT 의 시각(exp)은 초 단위다 */
|
||||
const MS_PER_SECOND = 1000;
|
||||
|
||||
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
|
||||
function accessTokenFromCookie(request: Request): string | null {
|
||||
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다
|
||||
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다).
|
||||
* 토큰은 웹은 HttpOnly 쿠키, 앱은 `Authorization: Bearer` 에서 읽는다 — Bearer 가 있으면 그것만 본다 (access-token.ts, 계약 1절 "앱 로그인").
|
||||
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, **길과 표시가 맞는지**(앱 토큰 `cli: 'app'` 은 Bearer 로만 ·
|
||||
* 표시가 없는 웹 토큰은 쿠키로만 — 서로 바꿔 쓰면 AUTH_001), 그리고 **끊은 세션인지**를 확인한다
|
||||
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다 — Bearer 도 같다).
|
||||
* 13단계: 제한(`rst`)이 실린 토큰만 세션 표의 **지금 제한**을 읽어 쓴다 — 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 세션의 제한을 지운 뒤
|
||||
* 옛 토큰으로 온 요청(새로고침 · 다른 탭)이 마친 단계로 되돌아가지 않게. 제한이 없는 토큰은 지금처럼 DB 를 묻지 않는다
|
||||
*/
|
||||
@@ -37,14 +33,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
private readonly sessions: AuthSessionRepository,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
|
||||
jwtFromRequest: (request: AccessTokenRequest) =>
|
||||
presentedAccessToken(request).token,
|
||||
secretOrKey: settings.jwtSecret,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
ignoreExpiration: false,
|
||||
// 토큰이 어느 길(쿠키 · Bearer)로 왔는지 validate 가 본다
|
||||
passReqToCallback: true,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: AuthTokenPayload): Promise<AuthenticatedUser> {
|
||||
async validate(
|
||||
request: AccessTokenRequest,
|
||||
payload: AuthTokenPayload,
|
||||
): Promise<AuthenticatedUser> {
|
||||
if (
|
||||
payload.typ !== 'access' ||
|
||||
typeof payload.sub !== 'string' ||
|
||||
@@ -61,7 +63,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
context: { reason: 'unknown-session-restriction' },
|
||||
});
|
||||
}
|
||||
if (payload.cli !== undefined && payload.cli !== APP_TOKEN_CLIENT) {
|
||||
// 서명은 맞는데 모르는 표시 — 웹 · 앱 어느 쪽으로도 받지 않는다 (닫힌 쪽)
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'unknown-token-client' },
|
||||
});
|
||||
}
|
||||
const isAppToken = payload.cli === APP_TOKEN_CLIENT;
|
||||
if (isAppToken !== (presentedAccessToken(request).transport === 'bearer')) {
|
||||
// 웹 토큰을 Bearer 로 · 앱 토큰을 쿠키로 — 서로 바꿔 쓰지 못한다 (계약 1절 "앱 로그인")
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: {
|
||||
reason: isAppToken ? 'app-token-in-cookie' : 'web-token-as-bearer',
|
||||
},
|
||||
});
|
||||
}
|
||||
const sessionId = typeof payload.sid === 'string' ? payload.sid : null;
|
||||
if (
|
||||
isAppToken &&
|
||||
(sessionId === null || !SESSION_ID_PATTERN.test(sessionId))
|
||||
) {
|
||||
// 앱 토큰은 늘 세션 id 를 싣는다(앱 1단계 뒤에만 있다) — 없거나 모양이 틀리면 지금 세션 · 끊은 세션을 알 수 없어 받지 않는다 (닫힌 쪽)
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'app-token-without-session' },
|
||||
});
|
||||
}
|
||||
if (sessionId !== null && this.revoked.isRevoked(sessionId)) {
|
||||
// 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다
|
||||
throw new BusinessException('AUTH_001', {
|
||||
@@ -76,6 +102,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
|
||||
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
|
||||
: {}),
|
||||
...(sessionId !== null ? { sessionId } : {}),
|
||||
// 앱 요청 — "지금 세션"을 리프레시 쿠키 대신 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
|
||||
...(isAppToken ? { client: 'app' as const } : {}),
|
||||
// 제한된 세션 · 2단계 인증 필수의 유예 (13단계) — 있을 때만. 가드가 제한을 본다(세션 표의 지금 값)
|
||||
...(payload.rst !== undefined
|
||||
? { restriction: await this.currentRestriction(sessionId) }
|
||||
|
||||
@@ -320,12 +320,14 @@ export function weightAfterChanges(
|
||||
export interface WrittenChanges {
|
||||
readonly createdIds: ReadonlyMap<string, string>;
|
||||
readonly cards: ReadonlyMap<string, AddressBookCard>;
|
||||
/** 엔진이 받지 않은 만들기의 key — 가져오기(일부만 받음)에만 있다. 옮기지 않는다 */
|
||||
readonly refusedKeys?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 받은 바꾸기를 기억한 주소록에 옮긴다 — 판이 같을 때만 쓰므로(ifInState) 엔진의 새 내용과 같다.
|
||||
* 연락처의 고치기 · 만든 카드는 어댑터가 준 모양(칸 안의 남의 정보와 합친 결과)을, 그룹 고치기는 보낸 이름 · 구성원을 쓴다.
|
||||
* 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
|
||||
* 엔진이 받지 않은 만들기(refusedKeys)는 건너뛴다. 그 밖의 만든 카드의 id 나 모양이 하나라도 없으면 null (옮길 수 없다 — 다시 읽는다)
|
||||
*/
|
||||
export function applyAddressBookWrite(
|
||||
snapshot: AddressBookSnapshot,
|
||||
@@ -352,6 +354,7 @@ export function applyAddressBookWrite(
|
||||
.map((card) => applyGroupUpdate(card, updates.get(card.id)));
|
||||
|
||||
for (const card of changes.create) {
|
||||
if (written.refusedKeys?.has(card.key)) continue;
|
||||
const id = written.createdIds.get(card.key);
|
||||
const read = id === undefined ? undefined : written.cards.get(id);
|
||||
if (read === undefined) return null;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
contactExportContentType,
|
||||
contactExportFileName,
|
||||
contactsToCsv,
|
||||
contactsToVcard,
|
||||
CSV_HEADER,
|
||||
foldVcardLine,
|
||||
type ExportedContact,
|
||||
} from './contact-export';
|
||||
|
||||
const contact = (
|
||||
overrides: Partial<ExportedContact> = {},
|
||||
): ExportedContact => ({
|
||||
uid: 'urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
name: '박지훈',
|
||||
emails: [{ address: 'jihoon.park@hanbit.example', label: 'work' }],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: '한빛상사',
|
||||
department: '구매팀',
|
||||
title: '과장',
|
||||
note: '견적 담당',
|
||||
groupNames: ['거래처'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 접힌 줄을 푼다 (CRLF + 공백 하나를 지운다 — RFC 2425 5.8.1) */
|
||||
const unfold = (text: string) => text.replace(/\r\n /g, '');
|
||||
|
||||
describe('내보내기 — vCard 3.0', () => {
|
||||
it('한 사람 — 줄 끝 CRLF, VERSION 은 BEGIN 바로 다음, 우리 칸만 (계약 3절 "내보내기")', () => {
|
||||
expect(contactsToVcard([contact()])).toBe(
|
||||
[
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'UID:urn:uuid:3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
'FN:박지훈',
|
||||
'N:박지훈;;;;',
|
||||
'EMAIL;TYPE=INTERNET,WORK:jihoon.park@hanbit.example',
|
||||
'TEL;TYPE=CELL:010-3192-5540',
|
||||
'ORG:한빛상사;구매팀',
|
||||
'TITLE:과장',
|
||||
'NOTE:견적 담당',
|
||||
'CATEGORIES:거래처',
|
||||
'END:VCARD',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('종류 — 이메일 WORK · HOME · INTERNET, 전화 CELL · WORK · HOME · VOICE', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
emails: [
|
||||
{ address: 'a@x.example', label: 'work' },
|
||||
{ address: 'b@x.example', label: 'personal' },
|
||||
{ address: 'c@x.example', label: 'other' },
|
||||
],
|
||||
phones: [
|
||||
{ number: '1', label: 'mobile' },
|
||||
{ number: '2', label: 'work' },
|
||||
{ number: '3', label: 'home' },
|
||||
{ number: '4', label: 'other' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,WORK:a@x.example\r\n');
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET,HOME:b@x.example\r\n');
|
||||
expect(text).toContain('\r\nEMAIL;TYPE=INTERNET:c@x.example\r\n');
|
||||
expect(text).toContain(
|
||||
'\r\nTEL;TYPE=CELL:1\r\nTEL;TYPE=WORK:2\r\nTEL;TYPE=HOME:3\r\nTEL;TYPE=VOICE:4\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('이스케이프 — \\ ; , 줄바꿈 (그룹 이름 안의 쉼표도)', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
name: '김; 아라, 팀장',
|
||||
company: 'A;B',
|
||||
department: null,
|
||||
note: '첫 줄\n둘째 줄 C:\\temp',
|
||||
groupNames: ['가,나', '다'],
|
||||
}),
|
||||
]);
|
||||
expect(text).toContain('\r\nFN:김\\; 아라\\, 팀장\r\n');
|
||||
expect(text).toContain('\r\nN:김\\; 아라\\, 팀장;;;;\r\n');
|
||||
expect(text).toContain('\r\nORG:A\\;B;\r\n');
|
||||
expect(text).toContain('\r\nNOTE:첫 줄\\n둘째 줄 C:\\\\temp\r\n');
|
||||
expect(text).toContain('\r\nCATEGORIES:가\\,나,다\r\n');
|
||||
});
|
||||
|
||||
it('빈 칸 — 이름이 없으면 FN 은 첫 이메일(N 은 비운다), 없는 칸 · uid 는 줄을 넣지 않는다, 회사가 없고 부서만 있으면 ORG:;부서', () => {
|
||||
const text = contactsToVcard([
|
||||
contact({
|
||||
uid: null,
|
||||
name: null,
|
||||
emails: [
|
||||
{ address: 'first@x.example', label: 'work' },
|
||||
{ address: 'second@x.example', label: 'other' },
|
||||
],
|
||||
phones: [{ number: '010-3192-5540', label: 'mobile' }],
|
||||
company: null,
|
||||
department: '구매팀',
|
||||
title: null,
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
expect(text).toBe(
|
||||
[
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:first@x.example',
|
||||
'N:;;;;',
|
||||
'EMAIL;TYPE=INTERNET,WORK:first@x.example',
|
||||
'EMAIL;TYPE=INTERNET:second@x.example',
|
||||
'TEL;TYPE=CELL:010-3192-5540',
|
||||
'ORG:;구매팀',
|
||||
'END:VCARD',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('이름 · 이메일이 없으면 FN 은 첫 전화번호, 그것도 없으면 빈 FN (N 은 늘 이름 칸 그대로)', () => {
|
||||
const phoneOnly = contactsToVcard([
|
||||
contact({
|
||||
name: null,
|
||||
emails: [],
|
||||
phones: [
|
||||
{ number: '02-555-0100', label: 'work' },
|
||||
{ number: '010-3192-5540', label: 'mobile' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(phoneOnly).toContain('\r\nFN:02-555-0100\r\nN:;;;;\r\n');
|
||||
|
||||
const nothing = contactsToVcard([
|
||||
contact({ name: null, emails: [], phones: [] }),
|
||||
]);
|
||||
expect(nothing).toContain('\r\nFN:\r\nN:;;;;\r\n');
|
||||
// 이름이 있으면 이름 — 주소 · 번호로 채우지 않는다
|
||||
expect(contactsToVcard([contact()])).toContain('\r\nFN:박지훈\r\n');
|
||||
});
|
||||
|
||||
it('줄 접기 — 75바이트마다(이어지는 줄은 공백 하나로 시작해 74바이트), 한글(3바이트)을 가르지 않는다', () => {
|
||||
const line = `NOTE:${'가'.repeat(60)}`;
|
||||
const folded = foldVcardLine(line);
|
||||
const lines = folded.split('\r\n');
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
for (const [index, part] of lines.entries()) {
|
||||
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
|
||||
if (index > 0) expect(part.startsWith(' ')).toBe(true);
|
||||
// 가른 곳이 글자 사이다 — 바이트로 바꿔도 깨진 글자(U+FFFD)가 없다
|
||||
expect(Buffer.from(part, 'utf8').toString('utf8')).not.toContain(
|
||||
'\uFFFD',
|
||||
);
|
||||
}
|
||||
expect(unfold(folded)).toBe(line);
|
||||
// 75바이트 이하는 그대로
|
||||
expect(foldVcardLine('A'.repeat(75))).toBe('A'.repeat(75));
|
||||
expect(foldVcardLine('A'.repeat(76))).toBe(`${'A'.repeat(75)}\r\n A`);
|
||||
// 이모지(4바이트 · 서로게이트 쌍)도 가르지 않는다
|
||||
const emoji = foldVcardLine(`FN:${'😀'.repeat(30)}`);
|
||||
expect(unfold(emoji)).toBe(`FN:${'😀'.repeat(30)}`);
|
||||
for (const part of emoji.split('\r\n')) {
|
||||
expect(Buffer.byteLength(part, 'utf8')).toBeLessThanOrEqual(75);
|
||||
expect(/[\uD800-\uDBFF]$/.test(part)).toBe(false);
|
||||
expect(/^ ?[\uDC00-\uDFFF]/.test(part)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('여러 명은 한 파일에 이어 붙인다 · 연락처가 없으면 빈 파일', () => {
|
||||
const text = contactsToVcard([contact(), contact({ name: '김아라' })]);
|
||||
expect(text.match(/BEGIN:VCARD/g)).toHaveLength(2);
|
||||
expect(text.endsWith('END:VCARD\r\n')).toBe(true);
|
||||
expect(contactsToVcard([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 — CSV', () => {
|
||||
it('UTF-8 BOM · CRLF · 한국어 머리 — 같은 칸 여럿은 "; ", 이메일은 종류 없이 모두', () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
emails: [
|
||||
{ address: 'a@x.example', label: 'work' },
|
||||
{ address: 'b@x.example', label: 'personal' },
|
||||
],
|
||||
phones: [
|
||||
{ number: '010-1111-2222', label: 'mobile' },
|
||||
{ number: '010-3333-4444', label: 'mobile' },
|
||||
{ number: '02-555-0100', label: 'work' },
|
||||
{ number: '031-000-0000', label: 'home' },
|
||||
{ number: '070-1234-5678', label: 'other' },
|
||||
],
|
||||
groupNames: ['거래처', '협력사'],
|
||||
}),
|
||||
]);
|
||||
expect(text.startsWith('\uFEFF')).toBe(true);
|
||||
expect(text).toBe(
|
||||
'\uFEFF' +
|
||||
'이름,이메일,휴대폰,회사 전화,집 전화,기타 전화,회사,부서,직함,메모,그룹\r\n' +
|
||||
'박지훈,a@x.example; b@x.example,010-1111-2222; 010-3333-4444,02-555-0100,031-000-0000,070-1234-5678,한빛상사,구매팀,과장,견적 담당,거래처; 협력사\r\n',
|
||||
);
|
||||
expect(CSV_HEADER).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("= + - @ 로 시작하는 칸은 앞에 ' (엑셀 수식 막기) · 쉼표 · 따옴표 · 줄바꿈은 따옴표로 감싼다", () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: '=HYPERLINK("http://x")',
|
||||
emails: [],
|
||||
phones: [{ number: '+82 10-1234-5678', label: 'mobile' }],
|
||||
company: '-주식회사',
|
||||
department: '@영업',
|
||||
title: '과장, 팀장',
|
||||
note: '첫 줄\n둘째 줄',
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
const row = text.split('\r\n')[1];
|
||||
expect(row).toBe(
|
||||
`"'=HYPERLINK(""http://x"")",,'+82 10-1234-5678,,,,'-주식회사,'@영업,"과장, 팀장","첫 줄\n둘째 줄",`,
|
||||
);
|
||||
});
|
||||
|
||||
it("탭 · CR 로 시작하는 칸도 앞에 ' (OWASP CSV Injection) — CR 이 든 칸은 따옴표로 감싼다", () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: '\t=1+1',
|
||||
emails: [],
|
||||
phones: [],
|
||||
company: '\r=1+1',
|
||||
department: null,
|
||||
title: '가\t나',
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
// 가운데의 탭은 그대로 — 첫 글자만 본다
|
||||
expect(text.slice(text.indexOf('\r\n') + 2)).toBe(
|
||||
`'\t=1+1,,,,,,"'\r=1+1",,가\t나,,\r\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('빈 칸은 비운다 · 연락처가 없으면 머리 줄만', () => {
|
||||
const text = contactsToCsv([
|
||||
contact({
|
||||
name: null,
|
||||
phones: [],
|
||||
company: null,
|
||||
department: null,
|
||||
title: null,
|
||||
note: null,
|
||||
groupNames: [],
|
||||
}),
|
||||
]);
|
||||
expect(text.split('\r\n')[1]).toBe(',jihoon.park@hanbit.example,,,,,,,,,');
|
||||
expect(contactsToCsv([])).toBe(`\uFEFF${CSV_HEADER.join(',')}\r\n`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('내보내기 — 파일 이름 · 형식', () => {
|
||||
// 2026-11-10 15:30 UTC = 2026-11-11 00:30 KST
|
||||
const at = new Date('2026-11-10T15:30:00.000Z');
|
||||
|
||||
it('주소록-YYYY-MM-DD(한국 날짜) · 그룹이면 주소록-<그룹 이름>-YYYY-MM-DD', () => {
|
||||
expect(contactExportFileName('vcard', null, at)).toBe(
|
||||
'주소록-2026-11-11.vcf',
|
||||
);
|
||||
expect(contactExportFileName('csv', '거래처', at)).toBe(
|
||||
'주소록-거래처-2026-11-11.csv',
|
||||
);
|
||||
});
|
||||
|
||||
it('그룹 이름의 예약 글자는 _ · 앞뒤 공백과 점은 뗀다 · 남는 것이 없으면 그룹 칸을 빼고 · 50글자까지', () => {
|
||||
expect(contactExportFileName('csv', ' 팀/파트:A* ', at)).toBe(
|
||||
'주소록-팀_파트_A_-2026-11-11.csv',
|
||||
);
|
||||
expect(contactExportFileName('csv', ' .. ', at)).toBe(
|
||||
'주소록-2026-11-11.csv',
|
||||
);
|
||||
expect(contactExportFileName('vcard', '가'.repeat(80), at)).toBe(
|
||||
`주소록-${'가'.repeat(50)}-2026-11-11.vcf`,
|
||||
);
|
||||
});
|
||||
|
||||
it('Content-Type — text/vcard · text/csv (UTF-8)', () => {
|
||||
expect(contactExportContentType('vcard')).toBe('text/vcard; charset=utf-8');
|
||||
expect(contactExportContentType('csv')).toBe('text/csv; charset=utf-8');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user