generated from ttipo/project-base
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 644c6e9424 | |||
| 41f220bf61 | |||
| f433f941e6 | |||
| 57340d43df | |||
| 3f66f39f9d | |||
| b77f95fdff | |||
| c0edff10d1 | |||
| 6ecc38d927 | |||
| f0ef782b24 | |||
| 67f63f05fc | |||
| 39623ae3a8 | |||
| a111cd2fd4 | |||
| 5d3a018708 | |||
| 6ee6c71ace | |||
| 2bb7fa4cdb | |||
| dc0fb81387 | |||
| 35fcf5e4ef | |||
| 5823f600aa | |||
| bb560d39f9 | |||
| fe355f3943 |
@@ -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` 복사 여부를 먼저 확인한다.
|
||||
|
||||
@@ -14,7 +14,7 @@ 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]])
|
||||
|
||||
@@ -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` 를 쓰는 사람이 새 키를 추가하지 않아도 안전해야 한다)
|
||||
|
||||
@@ -50,9 +50,9 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
|
||||
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
|
||||
|
||||
- **인증된 세션이 있는 상태**에서 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,6 +31,7 @@
|
||||
아래 값은 **어떤 레벨에서도 원문 그대로 남기지 않는다.**
|
||||
|
||||
- 비밀번호, 토큰(JWT·리프레시·API 키), 쿠키, `Authorization` 헤더
|
||||
- 앱 푸시(앱 1단계 — FCM)의 휴대폰 주소(FID — 지문도 남기지 않는다: 이어 붙여 기기를 좇지 못하게) · 서비스 계정 키 · 개인 키 · 서비스 계정 JWT · Google access token · FCM · Google 의 오류 설명 글(상태 · 오류 코드만 남긴다)
|
||||
- 주민등록번호·카드번호·계좌번호 등 개인 식별/금융 정보
|
||||
- 요청 본문 전체 덤프 (`JSON.stringify(req.body)` 금지 — 필요한 필드만 선택해 남긴다)
|
||||
- DB 쿼리 로그(매개변수) — **개발 환경도** 남기지 않는다. 메일 글 · 제목 · 주소가 DB 에 있다(14단계 검색 색인). TypeORM `logging` 은 개발에서 오류 · 경고 · 마이그레이션만, 운영 · 시험은 끈다 (`shared/config/database.config.ts` 의 `databaseLogging`)
|
||||
|
||||
@@ -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회용**으로 회전한다. 이미 쓴 토큰이 다시 오면 탈취로 보고 세션 전체를 끊는다.
|
||||
동시 갱신(탭 여러 개)을 위한 유예는 "바로 이전 토큰 + 짧은 시간"으로 좁히고, 유예 응답에서는 새 토큰을 주지 않는다 (훔친 토큰으로 얻는 것이 없게)
|
||||
- 로그인 실패 한도는 자격 확인 **전에** 실패 한 번을 원자적으로 차지하고 성공하면 돌려준다.
|
||||
@@ -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`) 적용
|
||||
|
||||
@@ -187,6 +187,9 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
그 막기는 사설 대역에 기댄다 — `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절 "실시간 알림"):
|
||||
@@ -498,7 +501,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 +511,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).
|
||||
@@ -591,7 +595,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` 동안 처리 중인 요청을 마치도록 한다.
|
||||
|
||||
+132
-98
@@ -3,7 +3,7 @@ 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]] 참조).
|
||||
@@ -12,25 +12,44 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
|
||||
|
||||
## 템플릿 기본 제공 골격
|
||||
|
||||
> 이 템플릿은 네트워크 계층을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 이 템플릿은 네트워크 · 세션 · 라우터 · 모듈 틀을 **기본 제공**한다. 새 코드는 이 파일들의 패턴을 재사용한다(같은 역할을 새로 만들지 말 것).
|
||||
> 앱 이름 "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 · 알림 토큰 지우기는 그 뒤. 제한이 풀리면 쥔 비밀번호를 지운다 |
|
||||
| 앱 로그인 저장소 | `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`)는 알리지 않는다 |
|
||||
| HTTP 클라이언트 | `lib/core/api/dio_client.dart` · `dio_provider.dart` | Dio 둘: 화면용(인터셉터 줄 `ResponseInterceptor` → `AuthInterceptor` → `ErrorNotifyInterceptor`)과 갱신 · 다시 보내기용(언래핑만). 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`(스트림 · 바이트는 그대로), 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`), 그 밖의 역할은 임시 값 |
|
||||
| 글꼴 | `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` 에 올린다(설정 › 앱 정보 › 오픈소스 라이선스) |
|
||||
| 푸시 기기 등록 자리 | `lib/core/push/` — `push_registration.dart` · `push_target.dart` | 모듈 공통. 지금은 `NoopPushRegistrar`(서버 `/api/push-devices` 가 생기면 채운다 — 세션 노티파이어가 로그인 뒤 `register` · 로그아웃 전 `unregister` 를 부른다). 알림 데이터에는 **모듈 이름과 id 만**(`{module, id}`) — `locationForPush` 가 모듈의 `locationOfItem` 으로 경로를 고른다 |
|
||||
| 자리 화면 | `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 +66,33 @@ 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)
|
||||
│ ├── push/ ← 푸시 기기 등록 자리 · 알림 → 경로
|
||||
│ ├── router/ ← 라우터 · 탭 막대 · redirect · 모듈 묶음 모으기(module_registry)
|
||||
│ ├── storage/ ← 토큰 보관 · 보안 저장소 · 첫 실행 표시
|
||||
│ ├── theme/ ← 색 · 간격 · 글자(임시) · 글꼴
|
||||
│ └── utils/ ← 순수 유틸 함수 (시계 등)
|
||||
├── features/
|
||||
│ └── user/
|
||||
│ ├── data/
|
||||
│ │ ├── models/ ← JSON 직렬화 모델
|
||||
│ │ └── repositories/ ← API 호출 구현체
|
||||
│ ├── domain/
|
||||
│ │ └── entities/ ← 순수 비즈니스 엔티티
|
||||
│ └── presentation/
|
||||
│ ├── pages/ ← 화면 단위 위젯
|
||||
│ ├── widgets/ ← 재사용 위젯
|
||||
│ └── providers/ ← 상태 관리
|
||||
│ ├── 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 +159,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,15 +273,17 @@ 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` 통과.
|
||||
- [ ] **모듈**: 새 기능은 그 모듈 폴더(`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` 통과. 배포 판은 `flutter build apk` 로 한 번 본다(release 매니페스트에 INTERNET 권한이 있어야 네트워크가 된다).
|
||||
Flutter 판은 위 "Flutter 판"(3.41.9) — CI 도 같은 판이다.
|
||||
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
|
||||
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
|
||||
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,7 +67,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
|
||||
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
|
||||
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
|
||||
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
|
||||
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다 — 계약 5절, 디자인 README "12단계" |
|
||||
| 계정 보안 (12단계) | 로그인 코드 단계 `src/components/auth/LoginCodeStep.vue` · `composables/useLoginChallenge.ts` · 설정 `pages/settings/AccountTwoFactorPage.vue` · `AccountAppPasswordsPage.vue` · `AccountLoginHistoryPage.vue`(+ 비밀번호 화면의 코드 칸 `PasswordChangeForm`) · `components/settings/`(`OtpCodeField` · `TwoFactorEnableDialog` + `TwoFactorEnrollmentScan` · `TwoFactorDisableDialog` · `AppPassword*` · `MailClientSettings` · `LoginSessionList` · `LoginEventTable` · `EndOtherSessionsDialog`) · QR `components/QrCodeImage.vue`(`uqr` 0.1.3 — SVG `<rect>` v-for, v-html 없음) · API `composables/useAccountSecurity.ts` · 스토어 `two-factor` · `app-passwords` · `login-history` · 상수 `shared/constants/account-security.ts`(상한 5 · 이름 40자 · 10분 · `MAIL_CLIENT_SETTINGS`) | **비밀은 화면 메모리에만** — 코드 단계의 비밀번호는 반응형이 아닌 클로저(`useLoginChallenge` — 성공 · [다른 계정으로 로그인] · 떠날 때 · 10분(타이머와 시계 `startedAt` 둘 다)에 지운다, 화면이 사라진 뒤의 늦은 `twoFactorRequired` 로는 시작하지 않는다), 2단계 키 · otpauth 주소 · 앱 비밀번호 값은 창 안에만 두고 닫을 때 지운다. **비밀이 드는 요청은 Pinia 액션에서 부르지 않는다** — devtools 타임라인이 액션의 인자 · 결과를 기록한다(창이 `useAccountSecurity` 로 보내고 스토어에는 비밀 없는 결과만 — `apply(status)` · `add(값을 뺀 줄)`). 세션 세대(`authStore.generation`)가 바뀌면 비밀이 든 창을 모두 닫는다(12단계부터 다른 기기의 로그아웃이 이 세션을 바로 끊는다). 창은 "몇 번째로 연 창인가"로 늦은 응답을 거른다 — 단 서버가 이미 켰거나 만든 결과는 반영한다. `login()` 은 `{ status }` 를 돌려준다. 코드로 나눈다: `AUTH_006` 칸 아래 · `AUTH_007` 코드 칸 보이기(6자리가 아닐 때도 이 문구) · `AUTH_008` 처음부터(새 키) · `AUTH_009` 상한. 지금 브라우저 줄이 없으면 [다른 곳 모두 로그아웃]은 "이 브라우저를 포함해"로 묻는다. **로그인된 기기**(2026-11-06 — 앱 세션이 함께 보인다): 웹 · 앱은 세션의 `client` · 로그인 기록의 `method` 로만 가른다(User-Agent 이름으로 짐작하지 않는다 — `shared/utils/account-security.ts` 의 `sessionCounts` · `isMobileSession`), 앱 이름은 `MOBILE_APP_NAME`(짝: backend `APP_BROWSER_NAME`) — 계약 5절, 디자인 README "12단계" |
|
||||
| 전환 준비 (13단계) | 제한된 세션 `stores/auth.store.ts`(`restriction` · `twoFactorDeadline` · `restrictionChecks` · `canUseApp`) · `shared/utils/auth-session.ts` · `router/index.ts` 가드 + `composables/useSessionRestriction.ts`(App) · `useApi.ts` 의 `recheckRestriction` · 로그인 틀 단계 `components/auth/LoginPasswordChangeStep.vue` · `LoginTwoFactorSetupStep.vue` · `LoginTwoFactorScan.vue`(QR 148 — `QrCodeImage` 의 `compact`) · `LoginAccountRow.vue` · `LoginSupportLine.vue` · `composables/useLoginSecret.ts` · `shared/constants/session-restriction.ts` · 문의 연락처 `stores/support-contact.store.ts` · `shared/utils/support-contact.ts` · 유예 줄 `components/mail/TwoFactorGraceNotice.vue`(AppShell 의 `notice` 슬롯 — MailLayout 에만) · 메일 프로그램 연결 `pages/settings/AccountMailClientsPage.vue` + `components/settings/MailClientGuideList.vue` · `shared/constants/mail-clients.ts` · 30일 줄 `MailListFrame`(`MAILBOX_RETENTION_NOTICES` · `MAILBOX_EMPTY_HINTS`) | **제한된 세션**(계약 6절): login · me · refresh 의 `restriction` 이 있으면 보호 화면 대신 로그인 틀의 그 단계(새 비밀번호 → 2단계 켜기)로 — 403 `AUTH_010` 은 알림 없이 me 를 다시 받고(제한 없음이면 refresh 후 원래 요청 한 번만), 서버가 제한을 확인할 때마다(`restrictionChecks`) 다시 옮긴다. 제한된 동안은 `canUseApp` 이 false 라 나가기 확인을 묻지 않고(세션이 끝난 것처럼 — 2026-11-02 리뷰: 확인에서 이동을 취소하면 메일 화면에 갇혔다) 실시간 알림 · 새 메일 확인을 열지 않는다. 모르는 제한 이름은 `unknown` — 갱신 · 다시 보내기 없이 사전 문구 + [다른 계정으로 로그인]만. **비밀**: 로그인 때 넣은 비밀번호는 반응형이 아닌 클로저에 10분(서버가 마지막으로 확인한 때부터) — 성공 · 다른 계정 · 떠남 · `isLoggedIn` false 때 지운다, 거절(`AUTH_005`)되면 지우고 칸을 보인다. 성공한 로그인 코드도 지운다(`resetCodeStep`). 단계를 마친 뒤 refresh 가 장애면 me 를 먼저 묻는다(제한이 있으면 그 단계 · 없으면 이어 감 · me 도 실패면 위쪽 상자 + [다시 시도]). 2단계를 켠 직원의 새 비밀번호 단계는 다음 코드를 받는다(로그인 코드는 2분 다시 못 쓴다). **유예 줄**: 높이 40 · [지금 켜기] 36(안내 줄 밀도 예외 재사용), 날짜는 `twoFactorDeadline` 의 1ms 앞(올해가 아니면 해), 닫기 없음, 마감이 있으면 `GET /two-factor` 를 한 번 물어 이미 켠 직원에게는 숨긴다. 설정 › 2단계 인증은 `isRequired` · `deadline` — 필수 · 켜짐이면 끄기 대신 글. **메일 프로그램 연결**: 펼침 줄 52(`--size-header-min` 재사용 — 예외 아님) · 한 번에 하나 · 처음 모두 접힘(h3 > button · `aria-expanded` · region), 글은 상수가 원본(디자인 README 13단계), 앱 비밀번호 화면에 바로가기 줄, 아이콘 `monitor-smartphone`. `PasswordField` · `OtpCodeField` 의 `variant="login"`(로그인 틀 칸 모양), `PasswordField` 의 `readonly`. 12단계 코드 단계의 도움말은 문의 줄로 바뀌었다(`LOGIN_TWO_FACTOR_TEXT.help` 삭제) — 계약 6절, 디자인 README "13단계" |
|
||||
| 시계 주입 | `src/composables/useClock.ts` · `src/test-utils/manual-clock.ts` | 타이머를 쓰는 로직(자동 저장 등)은 시계를 주입받는다 — 테스트는 손으로 돌리는 시계로 시간을 넘긴다(고정 대기 없이, [[testing]]) |
|
||||
| 나가기 전 확인 | `src/composables/useLeaveConfirm.ts` · `src/shared/utils/compose-changes.ts` | 화면을 연 뒤(또는 마지막으로 저장한 뒤) 바꾼 것이 있으면 앱 안 이동(라우트 떠남·바뀜)은 확인 대화상자, 탭 닫기·새로고침은 `beforeunload`. 쓰기 화면은 떠나기 전에 먼저 저장하고 저장하지 못한 것이 있을 때만 묻는다(서명 설정은 저장하지 않은 서명이 있으면 묻는다). 가드가 도는 중은 `guarding`. 바꿈 여부는 연 직후(저장한 뒤에는 마지막으로 저장한 판)의 스냅숏과 비교한다(편집기는 `isChanged()`). 떠나기 전 저장은 5초까지만 기다린다. 보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않는다 — 보내는 중에 떠나면 결과는 토스트로만 알리고 떠난 양식·새 양식을 건드리지 않는다(양식 세대로 늦게 온 결과를 버린다, 디자인 README "나가기 전 확인"). **고치던 모달 창**(필터 고치기 창 — 7단계)은 `composables/useDialogLeaveConfirm.ts` — 화면의 확인에 "창 닫기" 길을 더한다: [취소] · Esc 는 `asking: 'close'`([계속 고치기]면 창을 그대로 — 브라우저가 닫았으면 `reshow()`), 다른 화면은 `'route'`, 탭 닫기 · 새로고침은 `beforeunload`. 보내는 중 · 세션이 끝났을 때는 묻지 않는다 |
|
||||
|
||||
@@ -39,16 +39,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -9,12 +9,14 @@ 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 { 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 { SystemSettingsModule } from '@/modules/system-settings/system-settings.module';
|
||||
import { UploadModule } from '@/modules/upload/upload.module';
|
||||
import { UserModule } from '@/modules/user/user.module';
|
||||
@@ -80,6 +82,10 @@ 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,
|
||||
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 으로 막는다
|
||||
|
||||
@@ -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,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,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"`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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단계 인증은 그대로 남는다). ' +
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AccountSecurityModule } from '@/modules/account-security/account-securi
|
||||
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';
|
||||
@@ -19,6 +20,7 @@ import { LoginAttemptModule } from './login-attempt.module';
|
||||
|
||||
/**
|
||||
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
|
||||
* 웹은 쿠키(AuthController), 앱(모바일)은 토큰 본문 · Bearer(AppAuthController — 계약 1절 "앱 로그인") — 세션 규칙은 AuthService 하나다.
|
||||
*
|
||||
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
|
||||
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
|
||||
@@ -42,7 +44,7 @@ import { LoginAttemptModule } from './login-attempt.module';
|
||||
// 로그인한 직원의 검색 색인 상태 줄 (14단계 — 처음 로그인하면 building, 멈춰 있으면 푼다)
|
||||
MailSearchModule,
|
||||
],
|
||||
controllers: [AuthController, AuthSessionsController],
|
||||
controllers: [AuthController, AppAuthController, AuthSessionsController],
|
||||
providers: [
|
||||
{
|
||||
provide: AUTH_SETTINGS,
|
||||
|
||||
@@ -26,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';
|
||||
@@ -253,7 +254,7 @@ 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: {
|
||||
@@ -323,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),
|
||||
};
|
||||
@@ -1284,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(
|
||||
@@ -1307,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,
|
||||
@@ -1333,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',
|
||||
@@ -1454,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(
|
||||
@@ -1492,7 +1494,7 @@ describe('AuthService', () => {
|
||||
)?.code,
|
||||
).toBe('AUTH_006');
|
||||
}
|
||||
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ email: ARA, failureReason: 'wrongCode' }),
|
||||
);
|
||||
|
||||
@@ -1527,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 }),
|
||||
);
|
||||
});
|
||||
@@ -1540,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',
|
||||
@@ -1558,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 () => {
|
||||
@@ -1842,7 +1844,7 @@ describe('AuthService', () => {
|
||||
// 대리 접근이 막혀 있다 — 검색 색인의 멈춤을 풀지 않는다 (14단계)
|
||||
expect(searchIndex.enrollAfterLogin).not.toHaveBeenCalled();
|
||||
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
|
||||
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
|
||||
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: ARA, isSuccess: true }),
|
||||
);
|
||||
});
|
||||
@@ -2106,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,
|
||||
@@ -2197,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,
|
||||
@@ -45,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';
|
||||
@@ -101,6 +107,11 @@ export interface ClientInfo {
|
||||
readonly ip: string;
|
||||
/** User-Agent 원문 — 없으면 null */
|
||||
readonly userAgent?: string | null;
|
||||
/**
|
||||
* 웹(쿠키) · 앱(토큰 본문 — 계약 1절 "앱 로그인") — 없으면 웹. 로그인은 그 클라이언트의 세션을 열고(로그인 기록의 방식 webmail · app),
|
||||
* 갱신은 같은 클라이언트의 리프레시 토큰만 받는다
|
||||
*/
|
||||
readonly sessionClient?: SessionClient;
|
||||
}
|
||||
|
||||
/** 로그인의 선택 값 */
|
||||
@@ -108,6 +119,8 @@ export interface LoginOptions {
|
||||
/** 2단계 인증 코드 (숫자 6자리 — 형식은 DTO 가 본다) */
|
||||
readonly otpCode?: string;
|
||||
readonly userAgent?: string | null;
|
||||
/** 웹(쿠키) · 앱(토큰 본문) — 없으면 웹. 한도 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정은 둘이 같다 */
|
||||
readonly sessionClient?: SessionClient;
|
||||
}
|
||||
|
||||
/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */
|
||||
@@ -121,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 };
|
||||
@@ -205,6 +230,9 @@ export class AuthService {
|
||||
* 코드의 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다
|
||||
* 5. 틀림 → AUTH_004
|
||||
* 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006)는 로그인 기록에 남긴다 — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
|
||||
*
|
||||
* 앱 로그인(`options.sessionClient: 'app'` — 계약 1절 "앱 로그인")도 이 함수 하나를 지난다 — 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 ·
|
||||
* 막는 계정이 웹과 같다(앱 경로로 한도를 비켜 가지 못하게). 다른 것은 세션의 클라이언트(app — 토큰에 표시 cli)와 로그인 기록의 방식(app)뿐이다
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
@@ -221,6 +249,7 @@ export class AuthService {
|
||||
const client: ClientInfo = {
|
||||
ip: clientIp,
|
||||
userAgent: options.userAgent ?? null,
|
||||
sessionClient: options.sessionClient ?? 'web',
|
||||
};
|
||||
|
||||
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
|
||||
@@ -306,7 +335,8 @@ export class AuthService {
|
||||
// 검색 색인(14단계) — 만료된 계정은 대리 접근이 막혀 있어 멈춤을 풀지 않는다 (비밀번호를 바꾸면 changePassword 가 푼다)
|
||||
if (!passwordExpired)
|
||||
await this.searchIndex.enrollAfterLogin(profile.email);
|
||||
await this.loginEvents.recordWebmail({
|
||||
await this.loginEvents.recordSignIn({
|
||||
method: signInMethodOf(client),
|
||||
email: profile.email,
|
||||
isSuccess: true,
|
||||
isTwoFactor,
|
||||
@@ -315,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 };
|
||||
}
|
||||
@@ -369,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,
|
||||
@@ -405,6 +436,7 @@ export class AuthService {
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
|
||||
* 앱(`client.sessionClient: 'app'`)은 본문의 리프레시 토큰으로 같은 규칙을 탄다 (계약 1절 "앱 로그인") — 웹 토큰은 앱 갱신에서, 앱 토큰은 웹 갱신에서 받지 않는다(AUTH_001).
|
||||
*
|
||||
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
|
||||
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
|
||||
@@ -421,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);
|
||||
@@ -452,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 로그에 가린 주소와 끊은 세션 · 지운 앱 비밀번호 수만.
|
||||
@@ -542,7 +579,12 @@ 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,
|
||||
@@ -556,12 +598,12 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기) */
|
||||
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기). 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':
|
||||
@@ -606,7 +648,7 @@ export class AuthService {
|
||||
ticket,
|
||||
'abort',
|
||||
address,
|
||||
refreshToken,
|
||||
keepSessionId,
|
||||
'password-change-uncertain',
|
||||
);
|
||||
throw new BusinessException('MAIL_001', {
|
||||
@@ -623,7 +665,7 @@ export class AuthService {
|
||||
ticket,
|
||||
'succeed',
|
||||
address,
|
||||
refreshToken,
|
||||
keepSessionId,
|
||||
'password-changed',
|
||||
);
|
||||
this.logger.log(
|
||||
@@ -641,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 =
|
||||
@@ -711,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,
|
||||
@@ -737,8 +778,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
|
||||
* 비밀번호 바꾸기 · 로그인된 브라우저 목록(지금 세션 — 계약 5절)이 쓴다
|
||||
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰 · 앱 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
|
||||
* 웹 요청의 지금 세션이다 — 비밀번호 바꾸기 · 로그인된 브라우저 목록(계약 5절)이 requestSessionId 로 쓴다
|
||||
*/
|
||||
async currentSessionId(
|
||||
refreshToken: string | undefined,
|
||||
@@ -746,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절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게),
|
||||
* 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다.
|
||||
@@ -784,7 +838,7 @@ export class AuthService {
|
||||
|
||||
/**
|
||||
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
|
||||
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
* 토큰이 없거나 이미 쓸 수 없어도(앱 토큰 포함), 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
|
||||
*/
|
||||
async logout(refreshToken: string | undefined): Promise<void> {
|
||||
if (!refreshToken) return;
|
||||
@@ -792,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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -834,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,
|
||||
@@ -847,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),
|
||||
@@ -925,6 +1027,7 @@ export class AuthService {
|
||||
expiresAt,
|
||||
now,
|
||||
sessionState,
|
||||
session.client,
|
||||
);
|
||||
|
||||
const rotated = await this.sessions.rotate({
|
||||
@@ -945,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') {
|
||||
@@ -1016,7 +1121,10 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록) */
|
||||
/**
|
||||
* 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록).
|
||||
* 클라이언트(웹 · 앱)는 세션 표에 남기고 토큰에도 싣는다(앱이면 표시 cli) — 갱신은 같은 클라이언트로만 된다
|
||||
*/
|
||||
private async startSession(
|
||||
profile: MailAccountProfile,
|
||||
client: ClientInfo,
|
||||
@@ -1024,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,
|
||||
);
|
||||
@@ -1032,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,
|
||||
@@ -1048,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, {
|
||||
@@ -1072,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,
|
||||
@@ -1097,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 = {
|
||||
@@ -1110,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, {
|
||||
@@ -1136,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) }
|
||||
|
||||
@@ -1,71 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { runOutsideRequestContext } from '@/shared/logger/request-context';
|
||||
import { KeyedQueue } from '@/shared/utils/keyed-queue';
|
||||
|
||||
/**
|
||||
* 직원마다 색인 일을 한 번에 하나씩 (14단계 설계 B2 — "같은 직원은 한 번에 하나") — 맞추기 · 채우기 한 묶음 · 전체 대조가 같은 줄에 선다.
|
||||
* 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게.
|
||||
* 앞의 일이 실패해도 다음 일은 한다. 일은 요청 컨텍스트 밖에서 돈다 — 처음 부른 요청의 추적 ID 를 물려받지 않게 (차례는 shared/utils/keyed-queue.ts).
|
||||
*
|
||||
* ⚠️ 프로세스 안의 차례다 — 인스턴스를 늘리면 두 인스턴스가 같은 직원을 함께 맞춘다(쓰기는 상태 줄을 잠가 차례로 되지만 같은 일을 두 번 한다).
|
||||
* 그때는 잠금(Redis `SET NX PX` 또는 `pg_advisory_lock`)으로 옮긴다 (nestjs 스킬의 Redis 목록)
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailSearchRuns {
|
||||
private readonly tails = new Map<string, Promise<void>>();
|
||||
|
||||
/** 그 직원의 앞선 일이 끝난 뒤 `task` 를 한다 → 그 결과 */
|
||||
run<T>(user: string, task: () => Promise<T>): Promise<T> {
|
||||
const key = user.toLowerCase();
|
||||
const previous = this.tails.get(key) ?? Promise.resolve();
|
||||
const start = () => runOutsideRequestContext(task);
|
||||
const result = previous.then(start, start);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.tails.set(key, tail);
|
||||
void tail.then(() => {
|
||||
if (this.tails.get(key) === tail) this.tails.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 그 직원의 일이 줄에 있거나 도는 중인가 */
|
||||
isBusy(user: string): boolean {
|
||||
return this.tails.has(user.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 함께 도는 일의 수 — 넘는 일은 먼저 온 차례로 기다린다 (14단계 — 실시간 소식이 직원 수만큼 한꺼번에 와도 맞추기는 이만큼만 함께).
|
||||
* 앞의 일이 실패해도 자리는 돌려준다.
|
||||
*
|
||||
* ⚠️ 프로세스 안의 셈이다 — 인스턴스를 늘리면 인스턴스마다 따로 센다 (nestjs 스킬의 Redis 목록)
|
||||
*/
|
||||
export class ConcurrencyGate {
|
||||
private active = 0;
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
|
||||
constructor(private readonly limit: number) {}
|
||||
|
||||
/** 지금 도는 일 · 기다리는 일의 수 (시험용) */
|
||||
get load(): { readonly active: number; readonly waiting: number } {
|
||||
return { active: this.active, waiting: this.waiting.length };
|
||||
}
|
||||
|
||||
async run<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.limit) {
|
||||
await new Promise<void>((resolve) => this.waiting.push(resolve));
|
||||
} else {
|
||||
this.active += 1;
|
||||
}
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
// 기다리는 일에 자리를 그대로 넘긴다 (자리 수는 그대로) — 없으면 돌려준다
|
||||
const next = this.waiting.shift();
|
||||
if (next) next();
|
||||
else this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
export class MailSearchRuns extends KeyedQueue {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PasswordExpiredException,
|
||||
} from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { ConcurrencyGate } from '@/shared/utils/concurrency-gate';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { settledWithin } from '@/shared/utils/settle';
|
||||
import {
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
MailSearchIndexRepository,
|
||||
type SearchStatePatch,
|
||||
} from './mail-search-index.repository';
|
||||
import { ConcurrencyGate, MailSearchRuns } from './mail-search-runs';
|
||||
import { MailSearchRuns } from './mail-search-runs';
|
||||
import {
|
||||
MAIL_SEARCH_TIMING,
|
||||
SYSTEM_SEARCH_TIMING,
|
||||
|
||||
@@ -1967,6 +1967,127 @@ describe('JmapMailProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('앱 새 메일 알림의 N (countInboxUnreadSince)', () => {
|
||||
const mailboxes: JmapInvocation[] = [
|
||||
['Mailbox/get', { state: 'sb1', list: stalwartMailboxes }, 'm'],
|
||||
];
|
||||
|
||||
it('받은편지함을 찾은 뒤 시각마다 Email/query 하나(받은편지함 · $seen 아님 · $draft 아님 · after 는 그 초의 한 초 앞 — 엔진의 after 는 그 초를 넣지 않아서, 같은 초에 받은 메일도 세려고. limit 1 · calculateTotal) — 같은 차례로 total', async () => {
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxes,
|
||||
[
|
||||
['Email/query', { ids: ['m9'], total: 3 }, 'c0'],
|
||||
['Email/query', { ids: [], total: 0 }, 'c1'],
|
||||
],
|
||||
]);
|
||||
|
||||
const counts = await provider.countInboxUnreadSince(ARA, [
|
||||
new Date('2026-11-07T04:00:00.750Z'),
|
||||
new Date('2026-11-07T05:00:00.000Z'),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual([3, 0]);
|
||||
expect(recorded).toHaveLength(2);
|
||||
expect(recorded[0].calls[0][0]).toBe('Mailbox/get');
|
||||
expect(recorded[1].calls).toEqual([
|
||||
[
|
||||
'Email/query',
|
||||
{
|
||||
accountId: 'd',
|
||||
filter: {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: 'a' },
|
||||
{ notKeyword: '$seen' },
|
||||
{ notKeyword: '$draft' },
|
||||
{ after: '2026-11-07T03:59:59Z' },
|
||||
],
|
||||
},
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
},
|
||||
'c0',
|
||||
],
|
||||
[
|
||||
'Email/query',
|
||||
expect.objectContaining({
|
||||
filter: expect.objectContaining({
|
||||
conditions: expect.arrayContaining([
|
||||
{ after: '2026-11-07T04:59:59Z' },
|
||||
]) as unknown,
|
||||
}) as unknown,
|
||||
}),
|
||||
'c1',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('시각이 많으면 한 요청에 8개씩 · 빈 배열이면 엔진에 묻지 않는다 · 받은편지함이 없으면 모두 0', async () => {
|
||||
const answers = (count: number): JmapInvocation[] =>
|
||||
Array.from(
|
||||
{ length: count },
|
||||
(_, index): JmapInvocation => [
|
||||
'Email/query',
|
||||
{ ids: [], total: index },
|
||||
`c${index}`,
|
||||
],
|
||||
);
|
||||
const { provider, recorded } = fakeClient([
|
||||
mailboxes,
|
||||
answers(8),
|
||||
answers(2),
|
||||
]);
|
||||
const since = Array.from(
|
||||
{ length: 10 },
|
||||
(_, index) => new Date(Date.UTC(2026, 10, 7, 5, index)),
|
||||
);
|
||||
const counts = await provider.countInboxUnreadSince(ARA, since);
|
||||
expect(counts).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 0, 1]);
|
||||
expect(recorded.map(({ calls }) => calls.length)).toEqual([1, 8, 2]);
|
||||
|
||||
const none = fakeClient([]);
|
||||
await expect(
|
||||
none.provider.countInboxUnreadSince(ARA, []),
|
||||
).resolves.toEqual([]);
|
||||
expect(none.recorded).toHaveLength(0);
|
||||
|
||||
const noInbox = fakeClient([
|
||||
[
|
||||
[
|
||||
'Mailbox/get',
|
||||
{ list: stalwartMailboxes.filter(({ role }) => role !== 'inbox') },
|
||||
'm',
|
||||
],
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
noInbox.provider.countInboxUnreadSince(ARA, since.slice(0, 2)),
|
||||
).resolves.toEqual([0, 0]);
|
||||
expect(noInbox.recorded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('total 을 주지 않으면 받은 id 수로(0 · 1) — 0 인지는 맞다 · 메서드 오류는 그 코드로 던진다', async () => {
|
||||
const { provider } = fakeClient([
|
||||
mailboxes,
|
||||
[
|
||||
['Email/query', { ids: ['m1'] }, 'c0'],
|
||||
['Email/query', { ids: [] }, 'c1'],
|
||||
],
|
||||
]);
|
||||
await expect(
|
||||
provider.countInboxUnreadSince(ARA, [new Date(), new Date()]),
|
||||
).resolves.toEqual([1, 0]);
|
||||
|
||||
const failed = fakeClient([
|
||||
mailboxes,
|
||||
[['error', { type: 'serverFail' }, 'c0']],
|
||||
]);
|
||||
expect(
|
||||
await codeOf(failed.provider.countInboxUnreadSince(ARA, [new Date()])),
|
||||
).toBe('MAIL_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('실시간 알림 (watchMail)', () => {
|
||||
/** 처음 상태 응답 — 모든 메일함 · 메일 상태 · 발신 ID */
|
||||
const snapshotAnswer = (
|
||||
|
||||
@@ -691,6 +691,36 @@ function findInbox(mailboxes: readonly JmapMailbox[]): JmapMailbox | undefined {
|
||||
return mailboxes.find((mailbox) => mailbox.role?.toLowerCase() === 'inbox');
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 새 메일 알림의 N 을 셀 때 한 요청에 넣는 Email/query 수 — 엔진의 한 요청 메서드 수 한도(Stalwart 기본 16)보다 넉넉히 적게.
|
||||
* 한 직원의 기기는 10대까지고(등록 상한) 따라잡기는 기기마다 둘을 센다
|
||||
*/
|
||||
const COUNT_QUERIES_PER_REQUEST = 8;
|
||||
|
||||
/** 엔진의 받은 시각 단위 — 초 (Stalwart 는 receivedAt 을 초로 둔다) */
|
||||
const RECEIVED_AT_RESOLUTION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 받은편지함의 안 읽은 메일(초안 빼고) 가운데 그 시각의 초부터(같은 초 포함) 받은 것.
|
||||
* 엔진의 after 는 그 초를 넣지 않는다(2026-11-08 확인 — countInboxUnreadSince) — 그 초의 한 초 앞을 넘긴다.
|
||||
* ⚠️ 엔진이 RFC 대로 같은 초를 넣게 바뀌면 한 초 앞의 메일까지 세어, 따라잡기가 이미 알린 메일로 한 번 더 울린다 — 엔진 판을 올릴 때 다시 본다
|
||||
*/
|
||||
function inboxUnreadSinceFilter(inboxId: string, since: Date): JmapEmailFilter {
|
||||
const secondBefore =
|
||||
Math.floor(since.getTime() / RECEIVED_AT_RESOLUTION_MS) *
|
||||
RECEIVED_AT_RESOLUTION_MS -
|
||||
RECEIVED_AT_RESOLUTION_MS;
|
||||
return {
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ inMailbox: inboxId },
|
||||
{ notKeyword: '$seen' },
|
||||
{ notKeyword: '$draft' },
|
||||
{ after: toJmapUtcDate(new Date(secondBefore).toISOString()) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 실시간 알림의 상태 — 받은편지함이 없으면 안 읽은 수 0 */
|
||||
function toLiveState(
|
||||
states: MailStatesRead,
|
||||
@@ -1476,6 +1506,59 @@ export class JmapMailProvider implements MailProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 새 메일 알림의 N (계약 1절 "앱 새 메일 알림 · 앱 설정") — 받은편지함 찾기(Mailbox/get) 한 번 뒤 시각마다 Email/query
|
||||
* (받은편지함 · `$seen` 아님 · `$draft` 아님 · 받은 시각 after, limit 1 · calculateTotal). 한 요청에 COUNT_QUERIES_PER_REQUEST 개씩.
|
||||
* 세는 시작과 같은 초에 받은 메일도 센다(덜 세지 않게 — 포트의 약속). 엔진은 받은 시각을 초로 두고 after 는 **그 초를 넣지 않으므로**
|
||||
* ("그 초보다 뒤" — RFC 8621 은 "같거나 뒤"라 적지만 Stalwart 0.16.27 은 아니다, 2026-11-08 개발 엔진에서 확인) 한 초 앞을 넘긴다(inboxUnreadSinceFilter).
|
||||
* total 이 없으면 받은 id 수(0 · 1)로 — 적게 세더라도 0 인지는 맞다
|
||||
*/
|
||||
async countInboxUnreadSince(
|
||||
user: string,
|
||||
since: readonly Date[],
|
||||
): Promise<number[]> {
|
||||
if (since.length === 0) return [];
|
||||
const inbox = findInbox(await this.loadMailboxes(user));
|
||||
if (!inbox) return since.map(() => 0);
|
||||
|
||||
const counts: number[] = [];
|
||||
for (
|
||||
let start = 0;
|
||||
start < since.length;
|
||||
start += COUNT_QUERIES_PER_REQUEST
|
||||
) {
|
||||
const chunk = since.slice(start, start + COUNT_QUERIES_PER_REQUEST);
|
||||
const responses = await this.jmap.call(user, (accountId) =>
|
||||
chunk.map(
|
||||
(at, index): JmapInvocation => [
|
||||
'Email/query',
|
||||
{
|
||||
accountId,
|
||||
filter: inboxUnreadSinceFilter(inbox.id, at),
|
||||
limit: 1,
|
||||
calculateTotal: true,
|
||||
},
|
||||
`c${index}`,
|
||||
],
|
||||
),
|
||||
);
|
||||
chunk.forEach((_, index) => {
|
||||
const result = expectResult<JmapQueryResult>(
|
||||
responses,
|
||||
`c${index}`,
|
||||
'Email/query',
|
||||
);
|
||||
const received = result.ids?.length ?? 0;
|
||||
counts.push(
|
||||
typeof result.total === 'number' && result.total >= received
|
||||
? result.total
|
||||
: received,
|
||||
);
|
||||
});
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async listMessages(
|
||||
user: string,
|
||||
query: MessageListQuery,
|
||||
|
||||
@@ -619,4 +619,149 @@ describe('MailEventsService (실시간 알림의 연결 관리)', () => {
|
||||
await connected(ARA);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('붙잡기 (앱 새 메일 알림 — hold)', () => {
|
||||
/** 붙잡은 쪽 대역 — 받은 신호를 센다 */
|
||||
const holder = () => {
|
||||
const signals: string[] = [];
|
||||
return {
|
||||
signals,
|
||||
holder: {
|
||||
newMail: () => signals.push('newMail'),
|
||||
ended: (cause: string) => signals.push(`ended:${cause}`),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('SSE 연결이 없어도 엔진 연결을 열어 두고 새 메일 신호를 받는다 — 보낸 사람 · 제목은 넘기지 않는다(신호뿐)', async () => {
|
||||
const held = holder();
|
||||
await service.hold(ARA, held.holder);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
fake.opened[0].listener.newMail(NEW_MAIL);
|
||||
expect(held.signals).toEqual(['newMail']);
|
||||
|
||||
// 붙잡은 동안은 닫지 않는다 — 머무는 시간이 지나도
|
||||
await advance(MAIL_EVENTS_LINGER_MS * 3);
|
||||
expect(fake.opened[0].close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SSE 연결과 엔진 연결 하나를 함께 쓴다 — 새 메일은 둘 다 받고, 붙잡기는 연결 상한에 세지 않는다', async () => {
|
||||
const held = holder();
|
||||
await service.hold(ARA, held.holder);
|
||||
const connections: Client[] = [];
|
||||
for (let i = 0; i < MAX_MAIL_EVENT_CONNECTIONS; i += 1) {
|
||||
connections.push(await connected(ARA));
|
||||
}
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
fake.opened[0].listener.newMail(NEW_MAIL);
|
||||
expect(held.signals).toEqual(['newMail']);
|
||||
expect(connections[0].events.at(-1)).toEqual({
|
||||
type: 'new-mail',
|
||||
data: NEW_MAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it(`놓으면 남은 연결이 없을 때 ${MAIL_EVENTS_LINGER_MS / 1000}초 뒤 닫는다 — SSE 가 남아 있으면 그대로, 놓기는 몇 번 불러도 한 번`, async () => {
|
||||
const release = await service.hold(ARA, holder().holder);
|
||||
const sse = await connected(ARA);
|
||||
release();
|
||||
release();
|
||||
await advance(MAIL_EVENTS_LINGER_MS);
|
||||
expect(fake.opened[0].close).not.toHaveBeenCalled();
|
||||
|
||||
sse.leave();
|
||||
await advance(MAIL_EVENTS_LINGER_MS);
|
||||
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('웹(SSE) 연결이 모두 떠나도 붙잡은 엔진 연결은 머무는 시간이 지나도 닫지 않고, 새 메일 신호가 그대로 온다 (2026-11-07 리뷰 M2)', async () => {
|
||||
const held = holder();
|
||||
await service.hold(ARA, held.holder);
|
||||
const first = await connected(ARA);
|
||||
const second = await connected(ARA);
|
||||
first.leave();
|
||||
second.leave();
|
||||
await advance(MAIL_EVENTS_LINGER_MS * 3);
|
||||
|
||||
expect(fake.opened[0].close).not.toHaveBeenCalled();
|
||||
fake.opened[0].listener.newMail(NEW_MAIL);
|
||||
expect(held.signals).toEqual(['newMail']);
|
||||
// 다시 들어온 웹 연결도 같은 엔진 연결을 쓴다
|
||||
await connected(ARA);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('서버 종료가 아닌데 허브가 닫히면 붙잡은 쪽에 ended(closed) 를 알린다(방어 — 붙잡은 줄로 알고 소리 없이 멈추지 않게), 서버 종료로 닫힐 때는 알리지 않는다', async () => {
|
||||
const held = holder();
|
||||
await service.hold(ARA, held.holder);
|
||||
// 지금 코드에는 이 길이 없다(머무는 시간은 붙잡은 쪽이 없을 때만 돈다) — 허브 닫기를 직접 불러 방어를 본다
|
||||
const internals = service as unknown as {
|
||||
hubs: Map<string, unknown>;
|
||||
closeHub(hub: unknown): void;
|
||||
};
|
||||
internals.closeHub(internals.hubs.get(ARA));
|
||||
expect(held.signals).toEqual(['ended:closed']);
|
||||
expect(fake.opened[0].close).toHaveBeenCalledTimes(1);
|
||||
|
||||
const atShutdown = holder();
|
||||
await service.hold(DOYUN, atShutdown.holder);
|
||||
service.beforeApplicationShutdown();
|
||||
expect(atShutdown.signals).toEqual([]);
|
||||
});
|
||||
|
||||
it('계정을 더 쓸 수 없어 끝나면(ended) 붙잡은 쪽에도 알린다 — 그 뒤 신호는 오지 않는다', async () => {
|
||||
const held = holder();
|
||||
const release = await service.hold(ARA, held.holder);
|
||||
fake.opened[0].listener.ended();
|
||||
expect(held.signals).toEqual(['ended:account-unusable']);
|
||||
release();
|
||||
|
||||
await service.hold(ARA, holder().holder);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('처음 엔진 연결이 실패하면 던지고 허브를 남기지 않는다 — 다음 붙잡기는 처음부터 다시 연다 · 서버 종료 중이면 MAIL_001', async () => {
|
||||
fake.holdNext();
|
||||
const holding = service.hold(ARA, holder().holder);
|
||||
await flush();
|
||||
fake.fail(
|
||||
new BusinessException('AUTH_001', { context: { reason: 'gone' } }),
|
||||
);
|
||||
await expect(holding).rejects.toMatchObject({ code: 'AUTH_001' });
|
||||
|
||||
await service.hold(ARA, holder().holder);
|
||||
expect(fake.watchMail).toHaveBeenCalledTimes(2);
|
||||
|
||||
service.beforeApplicationShutdown();
|
||||
await expect(service.hold(DOYUN, holder().holder)).rejects.toMatchObject({
|
||||
code: 'MAIL_001',
|
||||
});
|
||||
});
|
||||
|
||||
it('붙잡은 쪽이 던져도 엔진 연결 · 다른 쪽의 흐름은 이어진다 (error 로그 — 가린 주소)', async () => {
|
||||
const errors: string[] = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
await service.hold(ARA, {
|
||||
newMail: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
ended: () => undefined,
|
||||
});
|
||||
const held = holder();
|
||||
await service.hold(ARA, held.holder);
|
||||
const sse = await connected(ARA);
|
||||
|
||||
expect(() => fake.opened[0].listener.newMail(NEW_MAIL)).not.toThrow();
|
||||
expect(held.signals).toEqual(['newMail']);
|
||||
expect(sse.events.at(-1)?.type).toBe('new-mail');
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('a***@arasystem.test');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,23 @@ function expiryDelay(tokenExpiresAt: number | null): number {
|
||||
: remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡기가 끝난 까닭 — account-unusable: 계정을 더 쓸 수 없어 엔진 연결이 끝났다(금방 풀리지 않는다) ·
|
||||
* closed: 서버 종료가 아닌데 허브가 닫혔다(지금 코드에는 이 길이 없다 — 바뀌어도 붙잡은 쪽이 소리 없이 멈추지 않게 하는 방어)
|
||||
*/
|
||||
export type MailEventsHoldEnd = 'account-unusable' | 'closed';
|
||||
|
||||
/**
|
||||
* 엔진 연결을 붙잡는 쪽 (앱 새 메일 알림 — 계약 1절 "앱 새 메일 알림 · 앱 설정") — SSE 연결이 없어도 그 직원의 엔진 연결을 열어 둔다.
|
||||
* 받는 것은 신호뿐이다 — 새 메일의 보낸 사람 · 제목을 넘기지 않는다(붙잡는 쪽은 수를 엔진에 따로 센다). 어느 것도 던지지 않아야 한다
|
||||
*/
|
||||
export interface MailEventsHolder {
|
||||
/** 받은편지함에 알릴 새 메일이 왔다 — 웹의 `new-mail` 과 같은 판정(jmap/new-mail.ts 의 selectNewMail) */
|
||||
newMail(): void;
|
||||
/** 붙잡기가 끝났다 — 다시 붙잡으려면 hold 를 새로 부른다. 서버 종료로 닫힐 때는 부르지 않는다 */
|
||||
ended(cause: MailEventsHoldEnd): void;
|
||||
}
|
||||
|
||||
interface Client {
|
||||
readonly sink: MailEventSink;
|
||||
/** 이 연결의 로그인 세션 id — 모르면 null */
|
||||
@@ -105,10 +122,20 @@ interface Client {
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 */
|
||||
/** 붙잡은 쪽 하나 */
|
||||
interface Holder {
|
||||
readonly holder: MailEventsHolder;
|
||||
/** hold 가 돌아갔다 — 그 전에 엔진 연결이 끝나면 ended 대신 hold 가 던진다(한 번만 알리게) */
|
||||
active: boolean;
|
||||
removed: boolean;
|
||||
}
|
||||
|
||||
/** 한 직원의 엔진 연결과 그것을 나눠 쓰는 클라이언트 연결들 · 붙잡은 쪽들 */
|
||||
interface Hub {
|
||||
readonly user: string;
|
||||
readonly clients: Set<Client>;
|
||||
/** 엔진 연결을 붙잡은 쪽 — 연결 상한에 세지 않는다. 하나라도 있으면 클라이언트 연결이 없어도 닫지 않는다 */
|
||||
readonly holders: Set<Holder>;
|
||||
/** 엔진 연결을 기다리는 연결 수 — 연결 상한에 함께 센다 */
|
||||
pending: number;
|
||||
watch: MailWatch | null;
|
||||
@@ -137,9 +164,11 @@ interface Hub {
|
||||
* - 끊은 세션(로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊기 — 12단계)의 연결은 다음 ping 때 `end` 없이 닫는다 —
|
||||
* 클라이언트는 끊김으로 보고 다시 이어 401 → 갱신 실패 → 세션 만료로 간다 (계정을 더 쓸 수 없을 때와 같은 길 — 계약 2절 "실시간 알림")
|
||||
* - 열고 닫기는 로그에 남기지 않는다 (반복되는 정상 흐름)
|
||||
* - 붙잡기(hold — 앱 새 메일 알림): 앱 알림을 켠 직원은 SSE 연결이 없어도 엔진 연결을 붙잡아 둔다. 엔진 연결은 SSE 와 함께 쓴다(직원마다 여전히 하나),
|
||||
* 연결 상한에 세지 않는다. 붙잡은 쪽은 새 메일 신호와 끝남(ended)만 받는다. 놓으면 남은 연결이 없을 때 지금처럼 잠시 뒤 닫는다
|
||||
*
|
||||
* ⚠️ 연결 상한 · 엔진 연결은 서버 인스턴스마다다 — 인스턴스를 늘려도 기능은 맞다(인스턴스마다 엔진 연결 하나),
|
||||
* 다만 한 직원이 인스턴스마다 5개까지 열 수 있다.
|
||||
* 다만 한 직원이 인스턴스마다 5개까지 열 수 있다. 붙잡기도 인스턴스마다다(붙잡는 쪽이 한 인스턴스만 붙잡게 한다 — nestjs 스킬의 Redis 목록).
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailEventsService implements BeforeApplicationShutdown {
|
||||
@@ -186,14 +215,7 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
} catch (error) {
|
||||
hub.pending -= 1;
|
||||
// 엔진 연결을 열지 못했다 — 함께 기다린 연결이 모두 끝나면 허브를 버린다 (다음 연결이 처음부터 다시 연다)
|
||||
if (
|
||||
!hub.watch &&
|
||||
!hub.starting &&
|
||||
hub.clients.size === 0 &&
|
||||
hub.pending === 0
|
||||
) {
|
||||
this.closeHub(hub);
|
||||
}
|
||||
this.closeIfUnused(hub);
|
||||
throw error;
|
||||
}
|
||||
hub.pending -= 1;
|
||||
@@ -236,6 +258,39 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진 연결을 붙잡는다 (앱 새 메일 알림) — SSE 연결이 없어도 그 직원의 엔진 연결을 열어 두고 새 메일 신호를 받는다 → 놓기 함수(몇 번 불러도 한 번).
|
||||
* 엔진 연결은 SSE 와 함께 쓴다(이미 열렸으면 그대로 — 여는 중이면 함께 기다린다). 연결 상한에 세지 않는다.
|
||||
* 처음 엔진 연결의 실패는 던진다(MAIL_001 · AUTH_001 · REQ_001 — 부른 쪽이 나중에 다시), 서버 종료 중이면 MAIL_001.
|
||||
* 놓으면 남은 연결 · 붙잡은 쪽이 없을 때 지금처럼 MAIL_EVENTS_LINGER_MS 뒤 닫는다. 계정을 더 쓸 수 없어 끝나면 holder.ended()
|
||||
*/
|
||||
async hold(user: string, holder: MailEventsHolder): Promise<() => void> {
|
||||
if (this.shuttingDown) throw this.shuttingDownFailure();
|
||||
const hub = this.hubFor(user);
|
||||
const entry: Holder = { holder, active: false, removed: false };
|
||||
hub.holders.add(entry);
|
||||
this.cancelLinger(hub);
|
||||
try {
|
||||
await this.ensureWatch(hub);
|
||||
} catch (error) {
|
||||
this.dropHolder(hub, entry);
|
||||
throw error;
|
||||
}
|
||||
if (this.shuttingDown) {
|
||||
this.dropHolder(hub, entry);
|
||||
throw this.shuttingDownFailure();
|
||||
}
|
||||
if (entry.removed || hub.closed || !hub.watch) {
|
||||
// 기다리는 사이 엔진 연결이 끝났다 (계정을 더 쓸 수 없음 등)
|
||||
this.dropHolder(hub, entry);
|
||||
throw new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-watch-ended', user: maskEmail(user) },
|
||||
});
|
||||
}
|
||||
entry.active = true;
|
||||
return () => this.dropHolder(hub, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 직원의 모든 연결에 이벤트 하나를 보낸다 — 연결이 없으면 아무것도 하지 않는다(그 직원은 목록 상태로 안다).
|
||||
* 메일 엔진 밖의 소식(대용량 첨부의 위험 판정 `large-file` — 11단계)이 쓴다. 던지지 않는다
|
||||
@@ -271,6 +326,7 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
const hub: Hub = {
|
||||
user,
|
||||
clients: new Set(),
|
||||
holders: new Set(),
|
||||
pending: 0,
|
||||
watch: null,
|
||||
starting: null,
|
||||
@@ -309,17 +365,20 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
return hub.starting;
|
||||
}
|
||||
|
||||
/** 엔진 연결이 알리는 것을 그 직원의 모든 연결에 나눈다 */
|
||||
/** 엔진 연결이 알리는 것을 그 직원의 모든 연결 · 붙잡은 쪽에 나눈다 */
|
||||
private listenerFor(hub: Hub): MailWatchListener {
|
||||
return {
|
||||
state: (live) => {
|
||||
hub.live = live;
|
||||
this.broadcast(hub, { type: 'state', data: live });
|
||||
// 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있는 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다)
|
||||
// 메일이 바뀌었다 — 검색 색인도 맞춘다 (연결이 있거나 붙잡힌 동안만 오는 소식이다 — 없을 때는 5분 맞추기가 한다)
|
||||
this.searchSync.requestSync(hub.user);
|
||||
},
|
||||
newMail: (notice) =>
|
||||
this.broadcast(hub, { type: 'new-mail', data: notice }),
|
||||
newMail: (notice) => {
|
||||
this.broadcast(hub, { type: 'new-mail', data: notice });
|
||||
// 붙잡은 쪽에는 신호만 — 보낸 사람 · 제목은 넘기지 않는다
|
||||
this.signalHolders(hub, 'newMail');
|
||||
},
|
||||
upstream: (connected) => {
|
||||
if (hub.upstream === connected) return;
|
||||
hub.upstream = connected;
|
||||
@@ -327,14 +386,59 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
this.broadcast(hub, { type: 'ping', data: { upstream: connected } });
|
||||
},
|
||||
ended: () => {
|
||||
// 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다
|
||||
// 계정을 더 쓸 수 없다 — 연결을 닫는다. 클라이언트가 다시 이으면 원인 코드(AUTH_001 등)를 받는다.
|
||||
// 붙잡은 쪽에도 끝났다고 알린다 — closeHub 가 한다(붙잡는 중이던 쪽은 hold 가 던진다)
|
||||
hub.watch = null;
|
||||
for (const client of [...hub.clients]) this.closeClient(hub, client);
|
||||
this.closeHub(hub);
|
||||
this.closeHub(hub, 'account-unusable');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 붙잡은 쪽에 새 메일 신호 — 던져도 엔진 연결 · 다른 쪽의 흐름을 끊지 않는다 */
|
||||
private signalHolders(hub: Hub, signal: 'newMail'): void {
|
||||
for (const entry of [...hub.holders]) {
|
||||
if (entry.active && !entry.removed) {
|
||||
this.callHolder(hub, signal, () => entry.holder.newMail());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private callHolder(hub: Hub, signal: string, call: () => void): void {
|
||||
try {
|
||||
call();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 엔진 연결을 붙잡은 쪽에 소식을 전하지 못했습니다: user=${maskEmail(hub.user)}, signal=${signal}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 붙잡은 쪽을 뺀다 (몇 번 불러도 한 번만) — 엔진 연결이 없으면 바로, 있으면 남은 연결이 없을 때 잠시 뒤 닫는다 */
|
||||
private dropHolder(hub: Hub, entry: Holder): void {
|
||||
if (entry.removed) return;
|
||||
entry.removed = true;
|
||||
hub.holders.delete(entry);
|
||||
if (!this.closeIfUnused(hub)) this.lingerIfIdle(hub);
|
||||
}
|
||||
|
||||
/** 엔진 연결도 · 여는 중도 · 쓰는 쪽도 없으면 허브를 버린다 → 버렸으면 true (다음에 쓰는 쪽이 처음부터 다시 연다) */
|
||||
private closeIfUnused(hub: Hub): boolean {
|
||||
if (
|
||||
hub.closed ||
|
||||
hub.watch ||
|
||||
hub.starting ||
|
||||
hub.clients.size > 0 ||
|
||||
hub.pending > 0 ||
|
||||
hub.holders.size > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.closeHub(hub);
|
||||
return true;
|
||||
}
|
||||
|
||||
private broadcast(hub: Hub, event: MailEvent): void {
|
||||
for (const client of [...hub.clients]) this.send(hub, client, event);
|
||||
}
|
||||
@@ -389,6 +493,7 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
hub.closed ||
|
||||
hub.clients.size > 0 ||
|
||||
hub.pending > 0 ||
|
||||
hub.holders.size > 0 ||
|
||||
hub.lingerTimer
|
||||
) {
|
||||
return;
|
||||
@@ -429,8 +534,12 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
/** 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다) */
|
||||
private closeHub(hub: Hub): void {
|
||||
/**
|
||||
* 허브를 닫는다 — 엔진 연결 · 타이머를 정리하고 맵에서 뺀다 (남은 연결은 부른 쪽이 먼저 닫는다).
|
||||
* 남은 붙잡은 쪽은 빼고, 서버 종료가 아니면 붙잡기가 끝났다고 알린다(`cause` — 계정 문제가 아니면 closed).
|
||||
* 붙잡는 중이던 쪽(hold 가 아직 돌아가지 않음)에는 알리지 않는다 — hold 가 던진다
|
||||
*/
|
||||
private closeHub(hub: Hub, cause: MailEventsHoldEnd = 'closed'): void {
|
||||
if (hub.closed) return;
|
||||
hub.closed = true;
|
||||
this.cancelLinger(hub);
|
||||
@@ -439,5 +548,15 @@ export class MailEventsService implements BeforeApplicationShutdown {
|
||||
hub.watch?.close();
|
||||
hub.watch = null;
|
||||
if (this.hubs.get(hub.user) === hub) this.hubs.delete(hub.user);
|
||||
|
||||
const holders = [...hub.holders];
|
||||
hub.holders.clear();
|
||||
for (const entry of holders) entry.removed = true;
|
||||
if (this.shuttingDown) return;
|
||||
for (const entry of holders) {
|
||||
if (entry.active) {
|
||||
this.callHolder(hub, 'ended', () => entry.holder.ended(cause));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.mod
|
||||
import { MailSearchModule } from '@/modules/mail-search/mail-search.module';
|
||||
import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module';
|
||||
import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module';
|
||||
import { PushModule } from '@/modules/push/push.module';
|
||||
import { SystemMailModule } from '@/modules/system-mail/system-mail.module';
|
||||
import { isAllowedAttachment } from './attachment.policy';
|
||||
import { DraftController } from './draft.controller';
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
} from './mailbox.controller';
|
||||
import { MessageDeletionController } from './message-deletion.controller';
|
||||
import { MessageListService } from './message-list.service';
|
||||
import { NewMailPushService } from './new-mail-push.service';
|
||||
import { ConversationController } from './conversation.controller';
|
||||
import { MailConversationService } from './conversation.service';
|
||||
import { MessageController } from './message.controller';
|
||||
@@ -134,6 +136,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
SystemMailModule,
|
||||
// 검색 색인 (14단계) — 실시간 소식이 오면 맞춘다. 이 모듈은 메일을 가져오지 않는다(엔진 연결 MailEngineModule 만)
|
||||
MailSearchModule,
|
||||
// 앱 알림 (앱 1단계) — 등록 · 보내기(FCM). 이 모듈은 메일을 가져오지 않는다(메일 → 푸시 한 방향 — 사내 도메인만 MailEngineModule 에서)
|
||||
PushModule,
|
||||
],
|
||||
controllers: [
|
||||
MailboxController,
|
||||
@@ -179,6 +183,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions {
|
||||
MailConversationService,
|
||||
// 실시간 알림 — 직원마다 엔진 연결 하나를 나눠 쓰고, 서버 종료 때 열린 연결을 먼저 닫는다 (beforeApplicationShutdown)
|
||||
MailEventsService,
|
||||
// 앱 새 메일 알림 (앱 1단계) — 앱 알림을 켠 직원의 엔진 연결을 붙잡고, 새 메일이면 휴대폰에 "새 메일 N통"
|
||||
NewMailPushService,
|
||||
// 대용량 첨부(11단계) — 발송의 덩이 · 링크, 임시 저장의 파일, 읽기의 덩이 알아보기, 위험 판정 알림(large-file)
|
||||
MailLargeFilesService,
|
||||
RecipientSuggestionService,
|
||||
|
||||
@@ -251,6 +251,17 @@ export interface MailProvider {
|
||||
*/
|
||||
watchMail(user: string, listener: MailWatchListener): Promise<MailWatch>;
|
||||
|
||||
/**
|
||||
* 앱 새 메일 알림의 N (계약 1절 "앱 새 메일 알림 · 앱 설정") — 받은편지함의 안 읽은 메일(초안 빼고) 가운데 시각마다 그 초부터(같은 초 포함 — 받은 시각은 초) 받은 수.
|
||||
* 같은 초를 넣는지는 엔진마다 다르다 — 어댑터가 맞춘다(Stalwart 의 after 는 그 초를 넣지 않는다 — jmap-mail.provider.ts).
|
||||
* 시각 여럿을 한꺼번에 센다(같은 차례로 돌려준다 — 기기마다 세는 시작이 다르다). 받은편지함이 없으면 모두 0, 빈 배열이면 엔진에 묻지 않는다.
|
||||
* 엔진에 묻지 못하면 그 코드로 던진다(MAIL_001 · AUTH_001 · REQ_001)
|
||||
*/
|
||||
countInboxUnreadSince(
|
||||
user: string,
|
||||
since: readonly Date[],
|
||||
): Promise<number[]>;
|
||||
|
||||
/**
|
||||
* 메일 목록 (받은 시각 내림차순). `mailboxId` 가 없으면 휴지통·스팸메일함을 뺀 모든 메일함에서 찾는다 (검색).
|
||||
* `text` 는 엔진의 낱말 검색 규칙에 맞춰 넓혀 찾는다 (한국어 조사 — 계약 2절 "검색"). 없는 메일함은 RES_001.
|
||||
|
||||
@@ -98,6 +98,7 @@ function fakeProvider(): ProviderMocks {
|
||||
mockOf<'getAttachmentsTotalLimit'>().mockResolvedValue(25 * 1024 * 1024),
|
||||
getMailState: mockOf<'getMailState'>(),
|
||||
watchMail: mockOf<'watchMail'>(),
|
||||
countInboxUnreadSince: mockOf<'countInboxUnreadSince'>(),
|
||||
listMessages: mockOf<'listMessages'>(),
|
||||
searchScope: mockOf<'searchScope'>(),
|
||||
conversationScope: mockOf<'conversationScope'>(),
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { PushTarget } from '@/modules/push/push-registrations.repository';
|
||||
import type { PushRegistrationsService } from '@/modules/push/push-registrations.service';
|
||||
import type {
|
||||
PushNotice,
|
||||
PushSendOutcome,
|
||||
PushSender,
|
||||
} from '@/modules/push/push-sender.port';
|
||||
import type {
|
||||
MailEventsHolder,
|
||||
MailEventsService,
|
||||
} from './mail-events.service';
|
||||
import type { MailProvider } from './mail.provider';
|
||||
import {
|
||||
CATCH_UP_WINDOW_MS,
|
||||
HOLD_RETRY_DELAYS_MS,
|
||||
NEW_MAIL_PUSH_INTERVAL_MS,
|
||||
NewMailPushService,
|
||||
} from './new-mail-push.service';
|
||||
|
||||
const DOMAIN = 'arasystem.test';
|
||||
const ARA = `ara.kim@${DOMAIN}`;
|
||||
const DOYUN = `doyun.lee@${DOMAIN}`;
|
||||
const OUTSIDER = 'someone@e2e.arasystem.test';
|
||||
const NOW = Date.parse('2026-11-07T05:00:00Z');
|
||||
|
||||
const target = (
|
||||
sessionId: string,
|
||||
overrides: Partial<PushTarget> = {},
|
||||
): PushTarget => ({
|
||||
sessionId,
|
||||
fid: `fid-${sessionId}`,
|
||||
seenAt: new Date(NOW - 60 * 60_000),
|
||||
lastPushedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
/** 붙잡기 대역 — 붙잡은 쪽(holder)을 모으고, 열기를 붙잡거나 실패시킬 수 있다 */
|
||||
function fakeEvents() {
|
||||
const holds: { user: string; holder: MailEventsHolder; released: boolean }[] =
|
||||
[];
|
||||
const failures = new Map<string, unknown[]>();
|
||||
let gate: Promise<void> | null = null;
|
||||
const hold = jest.fn(async (user: string, holder: MailEventsHolder) => {
|
||||
if (gate) await gate;
|
||||
const queued = failures.get(user);
|
||||
if (queued && queued.length > 0) throw queued.shift();
|
||||
const entry = { user, holder, released: false };
|
||||
holds.push(entry);
|
||||
return () => {
|
||||
entry.released = true;
|
||||
};
|
||||
});
|
||||
return {
|
||||
events: { hold } as unknown as MailEventsService,
|
||||
hold,
|
||||
holds,
|
||||
/** 그 직원의 다음 붙잡기들을 이 실패로 */
|
||||
failNext: (user: string, ...errors: unknown[]) => {
|
||||
failures.set(user, [...(failures.get(user) ?? []), ...errors]);
|
||||
},
|
||||
/** 다음 붙잡기들을 문이 열릴 때까지 붙잡는다 */
|
||||
block: () => {
|
||||
let open!: () => void;
|
||||
gate = new Promise((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return () => {
|
||||
gate = null;
|
||||
open();
|
||||
};
|
||||
},
|
||||
active: (user: string) =>
|
||||
holds.filter((entry) => entry.user === user && !entry.released),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
available?: boolean;
|
||||
users?: string[];
|
||||
targets?: Record<string, PushTarget[]>;
|
||||
} = {},
|
||||
) {
|
||||
const fake = fakeEvents();
|
||||
let users = options.users ?? [ARA];
|
||||
const targets: Record<string, PushTarget[]> = options.targets ?? {
|
||||
[ARA]: [target('s1')],
|
||||
};
|
||||
/** 엔진의 받은편지함 안 읽은 메일의 받은 시각 (직원마다 — 엔진처럼 초로 둔다) */
|
||||
const unread: Record<string, number[]> = {};
|
||||
const counts: { user: string; since: number[] }[] = [];
|
||||
const provider = {
|
||||
// 포트처럼 — 받은 시각은 초, 세는 시작의 초부터 같은 초를 넣는다 (MailProvider.countInboxUnreadSince — 엔진의 after 는 그 초를 넣지 않아 어댑터가 한 초 앞을 넘긴다)
|
||||
countInboxUnreadSince: jest.fn((user: string, since: readonly Date[]) => {
|
||||
counts.push({ user, since: since.map((at) => at.getTime()) });
|
||||
return Promise.resolve(
|
||||
since.map((at) => {
|
||||
const after = Math.floor(at.getTime() / 1000) * 1000;
|
||||
return (unread[user] ?? []).filter(
|
||||
(time) => Math.floor(time / 1000) * 1000 >= after,
|
||||
).length;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
};
|
||||
let listener: ((user: string) => void) | null = null;
|
||||
/** 보내기 직전 확인에서 이제 받을 자리가 아닌 (세션, FID) — 그 휴대폰이 다른 세션으로 옮겨 갔다 */
|
||||
const moved = new Set<string>();
|
||||
const registrations = {
|
||||
usersWithTargets: jest.fn(() => Promise.resolve(users)),
|
||||
hasTargets: jest.fn((user: string) =>
|
||||
Promise.resolve((targets[user] ?? []).length > 0),
|
||||
),
|
||||
targetsOf: jest.fn((user: string) => Promise.resolve(targets[user] ?? [])),
|
||||
isDeliverable: jest.fn((sessionId: string, fid: string) =>
|
||||
Promise.resolve(!moved.has(`${sessionId}/${fid}`)),
|
||||
),
|
||||
// 표처럼 — 보낸 기기의 last_pushed_at 을 그 N 을 센 때로 (따라잡기 뒤 첫 보내기가 이것을 본다)
|
||||
recordPushed: jest.fn((sessionIds: readonly string[], at: Date) => {
|
||||
for (const list of Object.values(targets)) {
|
||||
list.forEach((entry, index) => {
|
||||
if (sessionIds.includes(entry.sessionId)) {
|
||||
list[index] = { ...entry, lastPushedAt: at };
|
||||
}
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
forgetDevice: jest.fn(() => Promise.resolve()),
|
||||
onChanged: jest.fn((next: (user: string) => void) => {
|
||||
listener = next;
|
||||
return () => {
|
||||
listener = null;
|
||||
};
|
||||
}),
|
||||
};
|
||||
const sent: { fid: string; notice: PushNotice }[] = [];
|
||||
const outcomes = new Map<string, PushSendOutcome>();
|
||||
const sender: PushSender = {
|
||||
available: options.available ?? true,
|
||||
// 보내는 쪽처럼 — 보내기 직전에 부른 쪽의 확인을 본다
|
||||
send: async (fid, notice, sendOptions) => {
|
||||
if (sendOptions?.stillValid && !(await sendOptions.stillValid())) {
|
||||
return 'skipped';
|
||||
}
|
||||
sent.push({ fid, notice });
|
||||
return outcomes.get(fid) ?? 'sent';
|
||||
},
|
||||
};
|
||||
const service = new NewMailPushService(
|
||||
fake.events,
|
||||
provider as unknown as MailProvider,
|
||||
registrations as unknown as PushRegistrationsService,
|
||||
sender,
|
||||
{ domain: DOMAIN },
|
||||
);
|
||||
return {
|
||||
service,
|
||||
fake,
|
||||
provider,
|
||||
registrations,
|
||||
sent,
|
||||
counts,
|
||||
outcomes,
|
||||
unread,
|
||||
targets,
|
||||
moved,
|
||||
setUsers: (next: string[]) => {
|
||||
users = next;
|
||||
},
|
||||
changed: (user: string) => listener?.(user),
|
||||
/** 그 직원의 붙잡은 쪽에 새 메일 신호 */
|
||||
newMail: (user: string) => {
|
||||
for (const entry of fake.active(user)) entry.holder.newMail();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('NewMailPushService (앱 새 메일 알림 — 붙잡기 · 간격 · N · 따라잡기)', () => {
|
||||
let logs: { level: string; text: string }[];
|
||||
let current: ReturnType<typeof setup> | null;
|
||||
|
||||
const advance = async (ms: number) => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
await flush();
|
||||
};
|
||||
|
||||
const start = async (options?: Parameters<typeof setup>[0]) => {
|
||||
current = setup(options);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
return current;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({
|
||||
now: NOW,
|
||||
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
|
||||
});
|
||||
logs = [];
|
||||
for (const level of ['error', 'warn', 'log', 'debug'] as const) {
|
||||
jest
|
||||
.spyOn(Logger.prototype, level)
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
logs.push({ level, text: String(args[0]) });
|
||||
});
|
||||
}
|
||||
current = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
current?.service.beforeApplicationShutdown();
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('서버가 뜨면 보낼 기기가 있는 사내 직원의 엔진 연결을 붙잡는다 — 사내 도메인 밖은 붙잡지 않는다', async () => {
|
||||
const { fake, registrations } = await start({
|
||||
users: [ARA, DOYUN, OUTSIDER],
|
||||
targets: { [ARA]: [target('s1')], [DOYUN]: [target('s2')] },
|
||||
});
|
||||
|
||||
expect(registrations.usersWithTargets).toHaveBeenCalledWith(`@${DOMAIN}`);
|
||||
expect(fake.holds.map(({ user }) => user).sort()).toEqual([ARA, DOYUN]);
|
||||
});
|
||||
|
||||
it('FCM 키가 없으면 아무도 붙잡지 않는다 — 엔진 연결을 늘리지 않는다', async () => {
|
||||
const { fake, registrations } = await start({ available: false });
|
||||
expect(registrations.usersWithTargets).not.toHaveBeenCalled();
|
||||
expect(fake.hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('함께 여는 엔진 연결은 4개까지 — 나머지는 앞의 것이 끝나면', async () => {
|
||||
current = setup({
|
||||
users: ['a1', 'a2', 'a3', 'a4', 'a5', 'a6'].map(
|
||||
(id) => `${id}@${DOMAIN}`,
|
||||
),
|
||||
targets: {},
|
||||
});
|
||||
const open = current.fake.block();
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(4);
|
||||
|
||||
open();
|
||||
await flush();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('5분 맞추기 — 등록이 빠진 직원은 놓고, 새 직원은 붙잡는다', async () => {
|
||||
const env = await start({ users: [ARA] });
|
||||
expect(env.fake.active(ARA)).toHaveLength(1);
|
||||
|
||||
env.setUsers([DOYUN]);
|
||||
await env.service.syncAll();
|
||||
await flush();
|
||||
expect(env.fake.active(ARA)).toHaveLength(0);
|
||||
expect(env.fake.active(DOYUN)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('등록이 바뀐 소식 — 그 직원만 다시 본다(기기가 생기면 붙잡고, 없어지면 놓는다)', async () => {
|
||||
const env = await start({ users: [], targets: {} });
|
||||
env.targets[DOYUN] = [target('s2')];
|
||||
env.changed(DOYUN);
|
||||
await flush();
|
||||
expect(env.fake.active(DOYUN)).toHaveLength(1);
|
||||
|
||||
env.targets[DOYUN] = [];
|
||||
env.changed(DOYUN);
|
||||
await flush();
|
||||
expect(env.fake.active(DOYUN)).toHaveLength(0);
|
||||
|
||||
env.changed(OUTSIDER);
|
||||
await flush();
|
||||
expect(env.registrations.hasTargets).not.toHaveBeenCalledWith(OUTSIDER);
|
||||
});
|
||||
|
||||
it('새 메일 신호 → 기기마다 N(그 기기에서 본 뒤 받은 안 읽은 메일)을 엔진에 세어 1 이상인 기기에만 "새 메일 N통" — 보낸 기기는 센 때를 적는다', async () => {
|
||||
const seenPhone = NOW - 10 * 60_000;
|
||||
const seenTablet = NOW - 1_000;
|
||||
const env = await start({
|
||||
targets: {
|
||||
[ARA]: [
|
||||
target('phone', { seenAt: new Date(seenPhone) }),
|
||||
target('tablet', { seenAt: new Date(seenTablet) }),
|
||||
],
|
||||
},
|
||||
});
|
||||
env.unread[ARA] = [NOW - 5 * 60_000, NOW - 2 * 60_000];
|
||||
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
|
||||
expect(env.counts.at(-1)).toEqual({
|
||||
user: ARA,
|
||||
since: [seenPhone, seenTablet],
|
||||
});
|
||||
expect(env.sent).toEqual([
|
||||
{ fid: 'fid-phone', notice: { kind: 'new-mail', count: 2 } },
|
||||
]);
|
||||
expect(env.registrations.recordPushed).toHaveBeenCalledWith(
|
||||
['phone'],
|
||||
new Date(NOW),
|
||||
);
|
||||
// 보낸 것은 debug — 가린 주소 · 기기 수 · N
|
||||
expect(logs.find((log) => log.level === 'debug')?.text).toBe(
|
||||
'앱 알림: user=a***@arasystem.test, 기기 1, N 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('N 이 0 이면(그사이 웹에서 읽음) 보내지 않는다', async () => {
|
||||
const env = await start();
|
||||
env.unread[ARA] = [];
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.sent).toEqual([]);
|
||||
expect(env.registrations.recordPushed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`같은 직원은 ${NEW_MAIL_PUSH_INTERVAL_MS / 1000}초에 한 번까지 — 첫 알림은 바로, 그사이 온 것은 끝에 그때의 N 으로 한 번`, async () => {
|
||||
const env = await start();
|
||||
env.unread[ARA] = [NOW];
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
]);
|
||||
|
||||
await advance(5_000);
|
||||
env.unread[ARA].push(Date.now());
|
||||
env.newMail(ARA);
|
||||
await advance(5_000);
|
||||
env.unread[ARA].push(Date.now());
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.sent).toHaveLength(1);
|
||||
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS - 10_000 - 1);
|
||||
expect(env.sent).toHaveLength(1);
|
||||
await advance(1);
|
||||
expect(env.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ kind: 'new-mail', count: 3 },
|
||||
]);
|
||||
|
||||
// 간격이 지난 뒤의 신호는 다시 바로
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만 — 지우는 쪽이 본다)', async () => {
|
||||
const env = await start({ targets: { [ARA]: [target('s1')] } });
|
||||
env.unread[ARA] = [NOW];
|
||||
env.outcomes.set('fid-s1', 'unregistered');
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.registrations.forgetDevice).toHaveBeenCalledWith('s1', 'fid-s1');
|
||||
expect(env.registrations.recordPushed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('보내는 알림은 종류와 개수뿐이다 — 붙잡은 쪽은 신호만 받으므로 메일의 보낸 사람 · 제목 · id 가 오지도 않는다', async () => {
|
||||
const env = await start();
|
||||
env.unread[ARA] = [NOW];
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
expect(env.fake.holds[0].holder.newMail.length).toBe(0);
|
||||
for (const { notice } of env.sent) {
|
||||
expect(Object.keys(notice).sort()).toEqual(['count', 'kind']);
|
||||
}
|
||||
});
|
||||
|
||||
it(`붙잡을 때마다 따라잡는다 — 그 전 ${CATCH_UP_WINDOW_MS / 60_000}분 안 · 마지막으로 보낸 뒤 · 본 뒤에 온 메일이 있으면 그때의 N 으로 한 번 (이미 알린 메일은 다시 알리지 않는다)`, async () => {
|
||||
const seenAt = NOW - 30 * 60_000;
|
||||
const lastPushedAt = NOW - 3 * 60_000;
|
||||
const targets = {
|
||||
[ARA]: [
|
||||
target('fresh', {
|
||||
seenAt: new Date(seenAt),
|
||||
lastPushedAt: new Date(lastPushedAt),
|
||||
}),
|
||||
],
|
||||
};
|
||||
// 본 뒤 받은 안 읽은 메일 셋 — 그 가운데 마지막으로 보낸 뒤에 받은 것은 하나
|
||||
current = setup({ targets });
|
||||
current.unread[ARA] = [NOW - 20 * 60_000, NOW - 5 * 60_000, NOW - 60_000];
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
|
||||
// 마지막으로 보낸 때는 다음 초부터 — 그 초에 받은 메일은 그 알림에 이미 들었다
|
||||
expect(current.counts[0]).toEqual({
|
||||
user: ARA,
|
||||
since: [lastPushedAt + 1_000, seenAt],
|
||||
});
|
||||
expect(current.sent).toEqual([
|
||||
{ fid: 'fid-fresh', notice: { kind: 'new-mail', count: 3 } },
|
||||
]);
|
||||
|
||||
// 마지막으로 보낸 뒤 · 10분 안에 온 메일이 없으면 보내지 않는다
|
||||
const quiet = setup({
|
||||
targets: {
|
||||
[DOYUN]: [
|
||||
target('old', {
|
||||
seenAt: new Date(seenAt),
|
||||
lastPushedAt: new Date(NOW - 60_000),
|
||||
}),
|
||||
],
|
||||
},
|
||||
users: [DOYUN],
|
||||
});
|
||||
quiet.unread[DOYUN] = [NOW - 20 * 60_000];
|
||||
quiet.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(quiet.sent).toEqual([]);
|
||||
quiet.service.beforeApplicationShutdown();
|
||||
});
|
||||
|
||||
it(`붙잡기에 실패하면 ${HOLD_RETRY_DELAYS_MS[0] / 60_000}분 뒤 다시 — 이어서 실패하면 warn 한 번(코드 · 사유 · 가린 주소), 다시 되면 info 한 번`, async () => {
|
||||
current = setup();
|
||||
const down = () =>
|
||||
new BusinessException('MAIL_001', {
|
||||
context: { reason: 'engine-down' },
|
||||
});
|
||||
current.fake.failNext(ARA, down(), down());
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(1);
|
||||
expect(logs.filter((log) => log.level === 'warn')).toHaveLength(0);
|
||||
|
||||
await advance(HOLD_RETRY_DELAYS_MS[0]);
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(2);
|
||||
const warns = logs.filter((log) => log.level === 'warn');
|
||||
expect(warns).toHaveLength(1);
|
||||
expect(warns[0].text).toContain('[MAIL_001]');
|
||||
expect(warns[0].text).toContain('user=a***@arasystem.test');
|
||||
expect(warns[0].text).toContain('reason=MAIL_001/engine-down');
|
||||
|
||||
// 두 번째 실패 뒤에는 5분 — 5분 맞추기가 그 전에 와도 기다린다
|
||||
await advance(HOLD_RETRY_DELAYS_MS[1] - 1);
|
||||
await current.service.syncAll();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(2);
|
||||
await advance(1);
|
||||
expect(current.fake.active(ARA)).toHaveLength(1);
|
||||
expect(
|
||||
logs.filter((log) => log.level === 'log').map((log) => log.text),
|
||||
).toEqual([
|
||||
'앱 새 메일 알림 — 메일 엔진 연결을 다시 붙잡았습니다: user=a***@arasystem.test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('계정을 쓸 수 없으면(AUTH_001) 60분부터 — 처음 실패에 warn', async () => {
|
||||
current = setup();
|
||||
current.fake.failNext(
|
||||
ARA,
|
||||
new BusinessException('AUTH_001', {
|
||||
context: { reason: 'account-gone' },
|
||||
}),
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(logs.filter((log) => log.level === 'warn')).toHaveLength(1);
|
||||
|
||||
await advance(HOLD_RETRY_DELAYS_MS[3] - 1);
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(1);
|
||||
await advance(1);
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('붙잡은 엔진 연결이 끝나면(ended — 계정을 더 쓸 수 없음) 60분 뒤 다시 붙잡는다', async () => {
|
||||
const env = await start();
|
||||
env.fake.holds[0].holder.ended('account-unusable');
|
||||
await flush();
|
||||
|
||||
await advance(HOLD_RETRY_DELAYS_MS[3] - 1);
|
||||
expect(env.fake.hold).toHaveBeenCalledTimes(1);
|
||||
await advance(1);
|
||||
expect(env.fake.hold).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it(`허브가 서버 종료가 아닌데 닫혔다면(ended closed — 방어) ${HOLD_RETRY_DELAYS_MS[0] / 60_000}분 뒤 다시 붙잡는다 — 붙잡은 줄로 알고 건너뛰지 않는다`, async () => {
|
||||
const env = await start();
|
||||
env.fake.holds[0].holder.ended('closed');
|
||||
await flush();
|
||||
// 5분 맞추기가 와도 기다림 안이면 건너뛰고, 1분 뒤 다시 붙잡는다
|
||||
await env.service.syncAll();
|
||||
expect(env.fake.hold).toHaveBeenCalledTimes(1);
|
||||
await advance(HOLD_RETRY_DELAYS_MS[0]);
|
||||
expect(env.fake.hold).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('따라잡기는 마지막 알림에 든 메일(보낸 때와 같은 초에 받은 메일)을 다시 알리지 않는다 — 받은 시각은 초이고 세기는 같은 초를 넣는다 (2026-11-07 리뷰 M1)', async () => {
|
||||
/** 그 메일이 들어온 초 · 그 메일의 신호로 N 을 센 때(같은 초 안 0.9초 뒤) */
|
||||
const received = NOW - 3 * 60_000;
|
||||
const lastPushedAt = new Date(received + 900);
|
||||
const seenAt = new Date(received - 30 * 60_000);
|
||||
current = setup({
|
||||
targets: { [ARA]: [target('s1', { seenAt, lastPushedAt })] },
|
||||
});
|
||||
current.unread[ARA] = [received];
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.holds).toHaveLength(1);
|
||||
expect(current.sent).toEqual([]);
|
||||
|
||||
// 그 다음 초에 받은 메일은 따라잡는다 — 그때의 N(본 뒤의 안 읽은 메일 둘)으로
|
||||
const next = setup({
|
||||
targets: { [DOYUN]: [target('s2', { seenAt, lastPushedAt })] },
|
||||
users: [DOYUN],
|
||||
});
|
||||
next.unread[DOYUN] = [received, received + 1_000];
|
||||
next.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(next.sent).toEqual([
|
||||
{ fid: 'fid-s2', notice: { kind: 'new-mail', count: 2 } },
|
||||
]);
|
||||
next.service.beforeApplicationShutdown();
|
||||
});
|
||||
|
||||
it('따라잡기는 본 때(seen_at)와 같은 초에 받은 메일로 울리지 않는다 — 이미 본 메일이다 · 그 다음 초에 받은 메일은 따라잡는다 (리뷰 M1 과 같은 까닭)', async () => {
|
||||
const received = NOW - 3 * 60_000;
|
||||
const seenAt = new Date(received + 400);
|
||||
current = setup({ targets: { [ARA]: [target('s1', { seenAt })] } });
|
||||
current.unread[ARA] = [received];
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.holds).toHaveLength(1);
|
||||
expect(current.sent).toEqual([]);
|
||||
|
||||
// N 은 본 때의 초부터 그대로 센다(덜 세지 않게) — 다음 초의 메일로 울리면 같은 초의 메일도 N 에 든다
|
||||
const next = setup({
|
||||
targets: { [DOYUN]: [target('s2', { seenAt })] },
|
||||
users: [DOYUN],
|
||||
});
|
||||
next.unread[DOYUN] = [received, received + 1_000];
|
||||
next.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(next.sent).toEqual([
|
||||
{ fid: 'fid-s2', notice: { kind: 'new-mail', count: 2 } },
|
||||
]);
|
||||
next.service.beforeApplicationShutdown();
|
||||
});
|
||||
|
||||
it('따라잡기도 간격에 든다 — 따라잡기로 보낸 바로 뒤의 새 메일은 간격 끝에 그때의 N 으로 (리뷰 L8)', async () => {
|
||||
current = setup({
|
||||
targets: {
|
||||
[ARA]: [target('s1', { seenAt: new Date(NOW - 30 * 60_000) })],
|
||||
},
|
||||
});
|
||||
current.unread[ARA] = [NOW - 60_000];
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
]);
|
||||
|
||||
// 따라잡기가 센 다음 초에 받은 메일 — 간격 끝 보내기는 따라잡기 뒤 새로 받은 메일이 있을 때만이다(리뷰 N1)
|
||||
current.unread[ARA].push(NOW + 1_000);
|
||||
current.newMail(ARA);
|
||||
await flush();
|
||||
expect(current.sent).toHaveLength(1);
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ kind: 'new-mail', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('붙잡은 직후 따라잡기가 세는 사이 그 메일의 새 메일 신호가 오면 한 번만 울린다 — 따라잡기는 건너뛰고 신호 쪽이 그때의 N 으로 (리뷰 N1)', async () => {
|
||||
current = setup();
|
||||
// 붙잡는 순간에 막 들어온 메일 — 따라잡기 창 안 · 본 뒤
|
||||
current.unread[ARA] = [NOW - 1_000];
|
||||
const counting =
|
||||
current.provider.countInboxUnreadSince.getMockImplementation();
|
||||
if (!counting) throw new Error('세기 대역이 있어야 한다');
|
||||
let open!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
// 따라잡기의 세기만 문이 열릴 때까지 붙잡는다
|
||||
current.provider.countInboxUnreadSince.mockImplementationOnce(
|
||||
async (user: string, since: readonly Date[]) => {
|
||||
await gate;
|
||||
return counting(user, since);
|
||||
},
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.holds).toHaveLength(1);
|
||||
|
||||
// 세기가 엔진에서 돌아오기 전에 그 메일의 신호가 온다(배달 → 신호 0.8초 남짓)
|
||||
current.newMail(ARA);
|
||||
open();
|
||||
await flush();
|
||||
expect(current.sent).toEqual([
|
||||
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
|
||||
]);
|
||||
|
||||
// 간격이 지나도 더 울리지 않는다
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('따라잡기로 보낸 뒤 그 메일의 신호가 늦게 오면 간격 끝에 다시 보내지 않는다 — 그사이 새로 받은 메일이 있을 때만, 보낸 때를 표에 적지 못해도 (리뷰 N1)', async () => {
|
||||
current = setup();
|
||||
current.unread[ARA] = [NOW - 1_000];
|
||||
// 표의 last_pushed_at 이 바뀌지 않은 것처럼 — 따라잡기가 보낸 기기 · 센 때는 서비스가 메모리에 둔다
|
||||
current.registrations.recordPushed.mockImplementation(() =>
|
||||
Promise.resolve(),
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.sent).toEqual([
|
||||
{ fid: 'fid-s1', notice: { kind: 'new-mail', count: 1 } },
|
||||
]);
|
||||
|
||||
// 따라잡기가 센 메일의 신호가 따라잡기 뒤에 온다
|
||||
jest.setSystemTime(NOW + 800);
|
||||
current.newMail(ARA);
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent).toHaveLength(1);
|
||||
|
||||
// 그 뒤의 신호는 보통대로 — 새로 받은 메일이 있으면 (건너뛴 보내기도 간격에 들어) 다음 간격 끝에 그때의 N 으로
|
||||
current.unread[ARA].push(Date.now());
|
||||
current.newMail(ARA);
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS);
|
||||
expect(current.sent.map(({ notice }) => notice)).toEqual([
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ kind: 'new-mail', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('보내기 직전 그 휴대폰이 다른 세션으로 옮겨 갔으면(isDeliverable false) 그 기기에는 보내지 않는다 — 보낸 때도 적지 않는다 (리뷰 L1)', async () => {
|
||||
const env = await start({
|
||||
targets: { [ARA]: [target('phone'), target('moved')] },
|
||||
});
|
||||
env.unread[ARA] = [NOW];
|
||||
env.moved.add('moved/fid-moved');
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
|
||||
expect(env.sent.map(({ fid }) => fid)).toEqual(['fid-phone']);
|
||||
expect(env.registrations.isDeliverable).toHaveBeenCalledWith(
|
||||
'moved',
|
||||
'fid-moved',
|
||||
);
|
||||
expect(env.registrations.recordPushed).toHaveBeenCalledWith(
|
||||
['phone'],
|
||||
new Date(NOW),
|
||||
);
|
||||
expect(env.registrations.forgetDevice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('붙잡기가 끝나면 간격 끝에 보낼 차례도 지운다 — 끝난 직원에게 기다리던 보내기가 돌지 않는다', async () => {
|
||||
const env = await start();
|
||||
env.unread[ARA] = [NOW];
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
env.newMail(ARA);
|
||||
env.fake.holds[0].holder.ended('account-unusable');
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS * 2);
|
||||
expect(env.sent).toHaveLength(1);
|
||||
expect(env.provider.countInboxUnreadSince).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('계정 문제로 60분을 기다리는 중에 등록이 바뀐 소식(다시 로그인 · 등록)이 오면 기다리지 않고 바로 붙잡아 본다', async () => {
|
||||
current = setup();
|
||||
current.fake.failNext(
|
||||
ARA,
|
||||
new BusinessException('AUTH_001', { context: { reason: 'expired' } }),
|
||||
);
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(1);
|
||||
|
||||
current.changed(ARA);
|
||||
await flush();
|
||||
expect(current.fake.hold).toHaveBeenCalledTimes(2);
|
||||
expect(current.fake.active(ARA)).toHaveLength(1);
|
||||
// warn 한 번 뒤 다시 붙잡았다 — info 한 번
|
||||
expect(logs.filter((log) => log.level === 'log')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('붙잡는 사이 등록이 빠졌으면 바로 놓는다', async () => {
|
||||
current = setup();
|
||||
const open = current.fake.block();
|
||||
current.service.onApplicationBootstrap();
|
||||
await flush();
|
||||
|
||||
current.targets[ARA] = [];
|
||||
current.changed(ARA);
|
||||
await flush();
|
||||
open();
|
||||
await flush();
|
||||
expect(current.fake.holds).toHaveLength(1);
|
||||
expect(current.fake.active(ARA)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('서버 종료 — 붙잡은 연결을 놓고, 간격 끝에 보낼 차례 · 다시 해 볼 차례를 지운다', async () => {
|
||||
const env = await start();
|
||||
env.unread[ARA] = [NOW];
|
||||
env.newMail(ARA);
|
||||
await flush();
|
||||
env.newMail(ARA);
|
||||
env.service.beforeApplicationShutdown();
|
||||
expect(env.fake.active(ARA)).toHaveLength(0);
|
||||
|
||||
await advance(NEW_MAIL_PUSH_INTERVAL_MS * 2);
|
||||
expect(env.sent).toHaveLength(1);
|
||||
current = null;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,647 @@
|
||||
import {
|
||||
type BeforeApplicationShutdown,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { PushTarget } from '@/modules/push/push-registrations.repository';
|
||||
import { PushRegistrationsService } from '@/modules/push/push-registrations.service';
|
||||
import { PUSH_SENDER, type PushSender } from '@/modules/push/push-sender.port';
|
||||
import { ConcurrencyGate } from '@/shared/utils/concurrency-gate';
|
||||
import { KeyedQueue } from '@/shared/utils/keyed-queue';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import { NEW_MAIL_MAX_AGE_MS } from './jmap/new-mail';
|
||||
import {
|
||||
MailEventsService,
|
||||
type MailEventsHoldEnd,
|
||||
type MailEventsHolder,
|
||||
} from './mail-events.service';
|
||||
import {
|
||||
AccountLoginDisabledException,
|
||||
MAIL_PROVIDER,
|
||||
type MailProvider,
|
||||
} from './mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from './mail.settings';
|
||||
|
||||
/** 같은 직원에게는 이 간격에 한 번까지 보낸다 — 첫 알림은 바로, 그사이 온 새 메일은 끝에 그때의 N 으로 한 번 */
|
||||
export const NEW_MAIL_PUSH_INTERVAL_MS = 30_000;
|
||||
/**
|
||||
* 붙잡기에 실패하면 다시 해 보는 간격 — 1 · 5 · 15 · 60분(끝 값을 계속 쓴다).
|
||||
* 계정을 쓸 수 없음(AUTH_001 · 비밀번호 만료 · 잠김 · 엔진 연결이 끝남)은 60분부터 — 금방 풀리지 않는다
|
||||
*/
|
||||
export const HOLD_RETRY_DELAYS_MS: readonly number[] = [
|
||||
60_000,
|
||||
5 * 60_000,
|
||||
15 * 60_000,
|
||||
60 * 60_000,
|
||||
];
|
||||
const ACCOUNT_PROBLEM_RETRY_STEP = HOLD_RETRY_DELAYS_MS.length - 1;
|
||||
/** 이만큼 이어서 붙잡지 못하면 warn 한 번 (계정을 쓸 수 없음은 처음에) — 엔진 재시작 같은 잠깐의 실패는 남기지 않는다 */
|
||||
const HOLD_WARN_AFTER_FAILURES = 2;
|
||||
/** 함께 여는 엔진 연결 수 — 서버가 뜰 때 직원 수만큼 엔진에 한꺼번에 몰리지 않게 */
|
||||
const HOLD_CONCURRENCY = 4;
|
||||
/**
|
||||
* 붙잡기 전에 온 메일을 따라잡는 범위 — 웹의 새 메일 규칙(jmap/new-mail.ts — 변화를 본 때보다 10분 넘게 앞서 받은 메일은 알리지 않는다)과 같다.
|
||||
* 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이에 온 메일이 이 안에 있으면 한 번 알린다
|
||||
*/
|
||||
export const CATCH_UP_WINDOW_MS = NEW_MAIL_MAX_AGE_MS;
|
||||
/** 붙잡을 직원 맞추기 — 5분마다(분의 15초 — 다른 5분 차례와 겹치지 않게). e2e 는 이 이름으로 멈춘다 */
|
||||
export const NEW_MAIL_PUSH_HOLDS_JOB = 'new-mail-push-holds';
|
||||
const NEW_MAIL_PUSH_HOLDS_CRON = '15 */5 * * * *';
|
||||
/** 붙잡기 맞추기의 차례 열쇠 — 5분 맞추기와 직원 한 사람 맞추기가 섞이지 않게 한 줄에 선다 */
|
||||
const SYNC_QUEUE_KEY = 'holds';
|
||||
|
||||
/** 직원마다 보내기 간격 */
|
||||
interface Pacing {
|
||||
lastFlushAt: number | null;
|
||||
/** 간격 끝에 보낼 차례 */
|
||||
timer: NodeJS.Timeout | null;
|
||||
/**
|
||||
* 따라잡기가 보냈고 그 뒤 신호 보내기가 아직 없다 — 다음 보내기 한 번은 새로 받은 메일이 있는 기기에만(리뷰 N1). 없으면 null.
|
||||
* 따라잡기가 센 메일의 새 메일 신호가 뒤늦게 와서 같은 메일로 한 번 더 울리지 않게
|
||||
*/
|
||||
afterCatchUp: CatchUpPushed | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 따라잡기가 보낸 기기(세션)와 그 N 을 센 때 — 메모리에 둔다(표의 last_pushed_at 저장이 실패해도 같은 메일로 다시 울리지 않게).
|
||||
* 보내지 못한 기기는 넣지 않는다(그 기기에는 그 메일이 아직 가지 않았다)
|
||||
*/
|
||||
interface CatchUpPushed {
|
||||
readonly sessions: ReadonlySet<string>;
|
||||
readonly countedAt: Date;
|
||||
}
|
||||
|
||||
/** 붙잡기에 실패한 직원 — 다음 시도 */
|
||||
interface HoldRetry {
|
||||
readonly failures: number;
|
||||
readonly dueAt: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
/** warn 을 남겼다 — 다시 붙잡으면 info 한 번 */
|
||||
readonly warned: boolean;
|
||||
}
|
||||
|
||||
/** 로그에 남길 실패 요약 — 코드 · 사유 · 엔진 오류 종류만 (엔진 설명 문장 · 주소 · 메일 글은 넣지 않는다) */
|
||||
function failureSummary(error: unknown): string {
|
||||
if (error instanceof BusinessException) {
|
||||
const reason = error.context?.reason;
|
||||
const jmapType = error.context?.jmapType;
|
||||
return [
|
||||
error.code,
|
||||
typeof reason === 'string' ? reason : null,
|
||||
typeof jmapType === 'string' ? jmapType : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
// DB 오류의 문장에는 값(주소 등)이 들어갈 수 있다 — 종류와 코드만
|
||||
const name = error instanceof Error ? error.name : typeof error;
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return typeof code === 'string' ? `${name}/${code}` : name;
|
||||
}
|
||||
|
||||
/** 로그 줄 앞의 코드 — 실패의 코드, 모르면 SYS_001 */
|
||||
function codeOf(error: unknown): string {
|
||||
return error instanceof BusinessException ? error.code : 'SYS_001';
|
||||
}
|
||||
|
||||
/** 계정을 쓸 수 없는 실패 — 금방 풀리지 않는다 (계정 없음 · 비밀번호 만료 AUTH_001 · 잠김) */
|
||||
function isAccountProblem(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof AccountLoginDisabledException ||
|
||||
(error instanceof BusinessException && error.code === 'AUTH_001')
|
||||
);
|
||||
}
|
||||
|
||||
/** 엔진의 받은 시각 단위 — 초 (Stalwart 는 receivedAt 을 초로 둔다) */
|
||||
const RECEIVED_AT_RESOLUTION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 그 시각의 다음 초(ms) — 따라잡기가 "새로 온 것"을 볼 시작. 없으면 0.
|
||||
* 세기는 그 초부터 같은 초를 넣으므로(MailProvider.countInboxUnreadSince — N 을 덜 세지 않게) 그대로면 그 초에 받은 메일
|
||||
* — 그 알림의 N 에 이미 들었거나(last_pushed_at) 이미 본(seen_at) — 이 "새로 온 것"으로 다시 세져 따라잡기가 한 번 더 울린다(2026-11-07 리뷰 M1).
|
||||
* 센 · 본 뒤 같은 초에 들어온 메일은 따로 새 메일 신호를 낸다 — 그 알림이 나가기 전(그 1초 안 · 30초 간격의 끝을 기다리는 사이)에 서버가 멈추면 놓친다.
|
||||
* 따라잡기 뒤 첫 보내기(Pacing.afterCatchUp)도 같은 규칙이라, 따라잡기가 센 그 초에 센 뒤 들어온 메일은 그 보내기에서 빠진다 — 다음 알림의 N 에는 든다
|
||||
*/
|
||||
function nextSecondOf(time: Date | null): number {
|
||||
if (time === null) return 0;
|
||||
return (
|
||||
Math.floor(time.getTime() / RECEIVED_AT_RESOLUTION_MS) *
|
||||
RECEIVED_AT_RESOLUTION_MS +
|
||||
RECEIVED_AT_RESOLUTION_MS
|
||||
);
|
||||
}
|
||||
|
||||
/** 프로세스를 붙잡지 않는 타이머 — 종료를 늦추지 않게 */
|
||||
function timer(callback: () => void, ms: number): NodeJS.Timeout {
|
||||
const handle = setTimeout(callback, ms);
|
||||
handle.unref();
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 새 메일 알림 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 메일 → 푸시 방향만이다(푸시 모듈은 메일을 모른다).
|
||||
*
|
||||
* - **붙잡기**: 보낼 기기(살아 있는 · 제한 없는 앱 세션의 등록)가 있는 사내 직원의 엔진 연결을 붙잡아 둔다(MailEventsService.hold — 5단계 실시간 알림과 같은 연결 ·
|
||||
* 같은 새 메일 판정). 서버가 뜰 때 · 5분마다 · 등록이 바뀐 직원은 그때 맞춘다. 함께 여는 수는 HOLD_CONCURRENCY. 실패하면 HOLD_RETRY_DELAYS_MS 뒤 다시.
|
||||
* FCM 키가 없으면 아무도 붙잡지 않는다(엔진 연결을 늘리지 않는다)
|
||||
* - **보내기**: 새 메일 신호 → 같은 직원은 NEW_MAIL_PUSH_INTERVAL_MS 에 한 번까지(첫 알림은 바로, 그사이 온 것은 끝에 한 번) → 그때 보낼 기기마다
|
||||
* N(받은편지함의 안 읽은 메일 가운데 그 기기에서 마지막으로 앱을 본 뒤 받은 수 — 엔진에 센다. 웹에서 읽은 메일은 빠진다) → N 이 1 이상인 기기에 "새 메일 N통".
|
||||
* FCM 이 받지 않는 기기는 등록을 지운다(FID 가 그대로일 때만). 같은 직원의 보내기는 차례로(KeyedQueue)
|
||||
* - **따라잡기**: 새로 붙잡을 때마다(서버를 다시 띄움 · 다시 붙잡음) 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있으면
|
||||
* 그 기기에 그때의 N 으로 한 번 — 이미 알린 메일은 다시 알리지 않는다(last_pushed_at). 붙잡은 직후의 메일 하나에 두 번 울리지 않게(리뷰 N1):
|
||||
* 붙잡은 뒤 새 메일 신호가 먼저 왔으면 따라잡기는 건너뛰고(신호 쪽이 그때의 N 으로), 따라잡기로 보낸 뒤 첫 신호 보내기는 그 뒤 새로 받은 메일이 있는 기기에만
|
||||
* - 보내는 글에는 개수만 — 새 메일의 보낸 사람 · 제목 · id 는 이 서비스에 오지도 않는다(붙잡은 쪽은 신호만 받는다)
|
||||
* 로그: 보낸 것은 debug(가린 주소 · 기기 수 · N), 붙잡기가 이어서 실패하면 warn 한 번 · 다시 되면 info 한 번. 메일 글 · FID · 토큰은 남기지 않는다
|
||||
*
|
||||
* ⚠️ 붙잡기 · 간격 · 차례는 프로세스 메모리다 — 인스턴스마다 붙잡으면 같은 새 메일을 인스턴스 수만큼 보낸다(N 은 엔진에서 세므로 수는 같고 알림만 겹친다).
|
||||
* 인스턴스를 늘리면 한 인스턴스만 붙잡게 한다 (nestjs 스킬의 Redis 목록)
|
||||
*/
|
||||
@Injectable()
|
||||
export class NewMailPushService
|
||||
implements OnApplicationBootstrap, BeforeApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(NewMailPushService.name);
|
||||
/** 붙잡은 직원 → 놓기 */
|
||||
private readonly held = new Map<string, () => void>();
|
||||
/** 붙잡는 중인 직원 */
|
||||
private readonly holding = new Set<string>();
|
||||
/** 붙잡을 직원 — 맞추기가 정한다 */
|
||||
private wanted = new Set<string>();
|
||||
private readonly retries = new Map<string, HoldRetry>();
|
||||
private readonly pacing = new Map<string, Pacing>();
|
||||
/**
|
||||
* 이번 붙잡기 뒤 새 메일 신호를 받은 직원 — 따라잡기는 건너뛴다(신호 쪽이 그때의 N 으로 보낸다 — 붙잡은 직후의 메일 하나에 두 번 울리지 않게, 리뷰 N1).
|
||||
* 붙잡기를 시작할 때 지운다(그 붙잡기의 연결이 낸 신호만 센다)
|
||||
*/
|
||||
private readonly signaledSinceHold = new Set<string>();
|
||||
private readonly holdGate = new ConcurrencyGate(HOLD_CONCURRENCY);
|
||||
/** 같은 직원의 보내기 · 따라잡기는 차례로 */
|
||||
private readonly sends = new KeyedQueue();
|
||||
/** 붙잡기 맞추기(5분 · 한 사람)는 한 줄에 — 읽고 정하는 사이 서로의 결정을 덮지 않게 */
|
||||
private readonly syncs = new KeyedQueue();
|
||||
/** 붙잡는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무도 붙잡지 않는다) */
|
||||
private readonly addressSuffix: string | null;
|
||||
private stopping = false;
|
||||
private syncingAll = false;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly events: MailEventsService,
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
private readonly registrations: PushRegistrationsService,
|
||||
@Inject(PUSH_SENDER) private readonly sender: PushSender,
|
||||
@Inject(MAIL_SETTINGS) settings: Pick<MailSettings, 'domain'>,
|
||||
) {
|
||||
const domain = settings.domain?.trim().toLowerCase();
|
||||
this.addressSuffix = domain ? `@${domain}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 뜬 뒤 — 등록이 바뀐 소식을 듣고, 붙잡을 직원을 맞춘다(기다리지 않는다 — 엔진 연결은 뒤에서 연다).
|
||||
* 등록이 바뀐 소식은 다시 해 볼 때를 기다리지 않는다 — 계정 문제로 60분 기다리던 중에 다시 로그인 · 등록했으면 바로 붙잡아 본다
|
||||
*/
|
||||
onApplicationBootstrap(): void {
|
||||
this.unsubscribe = this.registrations.onChanged((user) => {
|
||||
void this.syncUser(user, { resetRetry: true });
|
||||
});
|
||||
void this.syncAll();
|
||||
}
|
||||
|
||||
/** 붙잡을 직원을 모두 맞춘다 — 없는 직원은 붙잡고, 빠진 직원은 놓는다 (던지지 않는다) */
|
||||
@Cron(NEW_MAIL_PUSH_HOLDS_CRON, { name: NEW_MAIL_PUSH_HOLDS_JOB })
|
||||
async syncAll(): Promise<void> {
|
||||
if (this.stopping || this.syncingAll) return;
|
||||
this.syncingAll = true;
|
||||
try {
|
||||
await this.syncs.run(SYNC_QUEUE_KEY, async () => {
|
||||
const users =
|
||||
this.sender.available && this.addressSuffix !== null
|
||||
? await this.registrations.usersWithTargets(this.addressSuffix)
|
||||
: [];
|
||||
if (this.stopping) return;
|
||||
this.wanted = new Set(users.filter((user) => this.isCompany(user)));
|
||||
for (const user of [...this.held.keys()]) {
|
||||
if (!this.wanted.has(user)) this.release(user);
|
||||
}
|
||||
for (const user of [...this.retries.keys()]) {
|
||||
if (!this.wanted.has(user)) this.clearRetry(user);
|
||||
}
|
||||
for (const user of this.wanted) this.ensureHeld(user);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 새 메일 알림 — 붙잡을 직원을 읽지 못했습니다: ${failureSummary(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.syncingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 직원만 맞춘다 — 등록이 바뀌었을 때 · 다시 해 볼 때 (던지지 않는다).
|
||||
* `resetRetry` 면 붙잡기 실패의 기다림을 지우고 바로 해 본다(등록이 바뀐 소식 — 다시 로그인했을 수 있다)
|
||||
*/
|
||||
async syncUser(
|
||||
user: string,
|
||||
options: { readonly resetRetry?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
try {
|
||||
await this.syncs.run(SYNC_QUEUE_KEY, async () => {
|
||||
const want =
|
||||
this.sender.available &&
|
||||
this.isCompany(user) &&
|
||||
(await this.registrations.hasTargets(user));
|
||||
if (this.stopping) return;
|
||||
if (want) {
|
||||
this.wanted.add(user);
|
||||
if (options.resetRetry) this.allowRetryNow(user);
|
||||
this.ensureHeld(user);
|
||||
return;
|
||||
}
|
||||
this.wanted.delete(user);
|
||||
this.release(user);
|
||||
this.clearRetry(user);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 새 메일 알림 — 직원의 등록을 읽지 못했습니다: user=${maskEmail(user)}, ${failureSummary(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 종료 — 타이머를 멈추고 붙잡은 연결을 놓는다(엔진 연결은 MailEventsService 가 닫는다). 보내는 중인 요청은 보내는 쪽이 끊는다 */
|
||||
beforeApplicationShutdown(): void {
|
||||
this.stopping = true;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const pacing of this.pacing.values()) {
|
||||
if (pacing.timer) clearTimeout(pacing.timer);
|
||||
}
|
||||
this.pacing.clear();
|
||||
for (const retry of this.retries.values()) {
|
||||
if (retry.timer) clearTimeout(retry.timer);
|
||||
}
|
||||
this.retries.clear();
|
||||
for (const release of this.held.values()) release();
|
||||
this.held.clear();
|
||||
this.signaledSinceHold.clear();
|
||||
}
|
||||
|
||||
private isCompany(user: string): boolean {
|
||||
return (
|
||||
this.addressSuffix !== null &&
|
||||
user.trim().toLowerCase().endsWith(this.addressSuffix)
|
||||
);
|
||||
}
|
||||
|
||||
/** 붙잡혀 있지 않으면 붙잡기를 시작한다 — 다시 해 볼 때가 아니면 기다린다 */
|
||||
private ensureHeld(user: string): void {
|
||||
if (this.stopping || this.held.has(user) || this.holding.has(user)) return;
|
||||
const retry = this.retries.get(user);
|
||||
if (retry && retry.dueAt > Date.now()) return;
|
||||
this.holding.add(user);
|
||||
void this.holdGate.run(async () => {
|
||||
try {
|
||||
await this.hold(user);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 새 메일 알림 — 붙잡기를 마치지 못했습니다: user=${maskEmail(user)}, ${failureSummary(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.holding.delete(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async hold(user: string): Promise<void> {
|
||||
// 이 붙잡기의 연결이 낸 신호만 센다 (holder 는 hold 가 끝나기 바로 전에 켜진다 — 그 전에 지운다)
|
||||
this.signaledSinceHold.delete(user);
|
||||
let release: () => void;
|
||||
try {
|
||||
release = await this.events.hold(user, this.holderFor(user));
|
||||
} catch (error) {
|
||||
if (!this.stopping && this.wanted.has(user)) {
|
||||
this.scheduleRetry(user, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.stopping || !this.wanted.has(user)) {
|
||||
// 붙잡는 사이 등록이 빠졌다 · 서버가 닫힌다
|
||||
release();
|
||||
return;
|
||||
}
|
||||
this.held.set(user, release);
|
||||
this.recovered(user);
|
||||
// 붙잡기 전에 온 메일 — 서버를 다시 띄운 사이 · 다시 붙잡기까지의 사이 (그사이 신호가 오면 건너뛴다 — catchUp)
|
||||
void this.sends.run(user, () => this.catchUp(user));
|
||||
}
|
||||
|
||||
/** 붙잡은 연결의 신호 — 새 메일 · 끝남 */
|
||||
private holderFor(user: string): MailEventsHolder {
|
||||
return {
|
||||
newMail: () => this.onNewMail(user),
|
||||
ended: (cause) => this.onHoldEnded(user, cause),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙잡기가 끝났다 — 간격 끝에 보낼 차례도 지우고 다시 해 본다: 계정을 더 쓸 수 없음은 60분 뒤,
|
||||
* 그 밖(서버 종료가 아닌데 허브가 닫힘 — 방어)은 처음 실패처럼 1분 뒤
|
||||
*/
|
||||
private onHoldEnded(user: string, cause: MailEventsHoldEnd): void {
|
||||
this.held.delete(user);
|
||||
this.signaledSinceHold.delete(user);
|
||||
this.clearPacing(user);
|
||||
if (this.stopping || !this.wanted.has(user)) return;
|
||||
this.scheduleRetry(
|
||||
user,
|
||||
cause === 'account-unusable'
|
||||
? new BusinessException('AUTH_001', {
|
||||
context: { reason: 'mail-watch-ended' },
|
||||
})
|
||||
: new BusinessException('MAIL_001', {
|
||||
context: { reason: 'mail-watch-closed' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 놓는다 — 간격 끝에 보낼 차례도 지운다 */
|
||||
private release(user: string): void {
|
||||
const release = this.held.get(user);
|
||||
this.held.delete(user);
|
||||
this.signaledSinceHold.delete(user);
|
||||
release?.();
|
||||
this.clearPacing(user);
|
||||
}
|
||||
|
||||
private clearPacing(user: string): void {
|
||||
const pacing = this.pacing.get(user);
|
||||
if (pacing?.timer) clearTimeout(pacing.timer);
|
||||
this.pacing.delete(user);
|
||||
}
|
||||
|
||||
private scheduleRetry(user: string, error: unknown): void {
|
||||
const previous = this.retries.get(user);
|
||||
if (previous?.timer) clearTimeout(previous.timer);
|
||||
const failures = (previous?.failures ?? 0) + 1;
|
||||
const account = isAccountProblem(error);
|
||||
const step = Math.min(
|
||||
HOLD_RETRY_DELAYS_MS.length - 1,
|
||||
(account ? ACCOUNT_PROBLEM_RETRY_STEP : 0) + failures - 1,
|
||||
);
|
||||
const delay = HOLD_RETRY_DELAYS_MS[step];
|
||||
const warn =
|
||||
previous?.warned !== true &&
|
||||
(account || failures >= HOLD_WARN_AFTER_FAILURES);
|
||||
const entry: HoldRetry = {
|
||||
failures,
|
||||
dueAt: Date.now() + delay,
|
||||
warned: previous?.warned === true || warn,
|
||||
timer: timer(() => {
|
||||
entry.timer = null;
|
||||
void this.syncUser(user);
|
||||
}, delay),
|
||||
};
|
||||
this.retries.set(user, entry);
|
||||
if (warn) {
|
||||
this.logger.warn(
|
||||
`[${codeOf(error)}] 앱 새 메일 알림 — 메일 엔진 연결을 붙잡지 못했습니다: user=${maskEmail(user)}, reason=${failureSummary(error)}, failures=${failures} — ${Math.round(delay / 60_000)}분 뒤 다시 합니다`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 붙잡았다 — 실패 기록을 지운다(warn 을 남겼으면 info 한 번) */
|
||||
private recovered(user: string): void {
|
||||
const retry = this.retries.get(user);
|
||||
if (!retry) return;
|
||||
this.clearRetry(user);
|
||||
if (retry.warned) {
|
||||
this.logger.log(
|
||||
`앱 새 메일 알림 — 메일 엔진 연결을 다시 붙잡았습니다: user=${maskEmail(user)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRetry(user: string): void {
|
||||
const retry = this.retries.get(user);
|
||||
if (retry?.timer) clearTimeout(retry.timer);
|
||||
this.retries.delete(user);
|
||||
}
|
||||
|
||||
/** 기다림만 지운다 — 실패 수 · warn 여부는 남긴다(다시 실패하면 warn 을 겹쳐 남기지 않고, 붙잡으면 info 한 번) */
|
||||
private allowRetryNow(user: string): void {
|
||||
const retry = this.retries.get(user);
|
||||
if (!retry) return;
|
||||
if (retry.timer) clearTimeout(retry.timer);
|
||||
this.retries.set(user, { ...retry, dueAt: 0, timer: null });
|
||||
}
|
||||
|
||||
private pacingOf(user: string): Pacing {
|
||||
let pacing = this.pacing.get(user);
|
||||
if (!pacing) {
|
||||
pacing = { lastFlushAt: null, timer: null, afterCatchUp: null };
|
||||
this.pacing.set(user, pacing);
|
||||
}
|
||||
return pacing;
|
||||
}
|
||||
|
||||
/** 새 메일 신호 — 첫 알림은 바로, 간격 안이면 끝에 한 번 (이미 기다리는 차례가 있으면 그것이 그때의 N 으로 보낸다) */
|
||||
private onNewMail(user: string): void {
|
||||
if (this.stopping) return;
|
||||
this.signaledSinceHold.add(user);
|
||||
const pacing = this.pacingOf(user);
|
||||
if (pacing.timer) return;
|
||||
const wait =
|
||||
pacing.lastFlushAt === null
|
||||
? 0
|
||||
: pacing.lastFlushAt + NEW_MAIL_PUSH_INTERVAL_MS - Date.now();
|
||||
if (wait <= 0) {
|
||||
this.flush(user, pacing);
|
||||
return;
|
||||
}
|
||||
pacing.timer = timer(() => {
|
||||
pacing.timer = null;
|
||||
if (!this.stopping) this.flush(user, pacing);
|
||||
}, wait);
|
||||
}
|
||||
|
||||
private flush(user: string, pacing: Pacing): void {
|
||||
pacing.lastFlushAt = Date.now();
|
||||
const afterCatchUp = pacing.afterCatchUp;
|
||||
pacing.afterCatchUp = null;
|
||||
void this.sends.run(user, () => this.pushNewMail(user, afterCatchUp));
|
||||
}
|
||||
|
||||
/**
|
||||
* 보낼 기기마다 N 을 세어 보낸다 (던지지 않는다).
|
||||
* 따라잡기 뒤 첫 보내기(`afterCatchUp`)면 따라잡기처럼 새로 온 것이 있는 기기에만 — 따라잡기가 보낸 기기는 그 따라잡기가 센 초 다음부터 본다.
|
||||
* 따라잡기가 센 메일의 신호가 뒤늦게 와서 간격 끝에 같은 메일로 한 번 더 울리지 않게(리뷰 N1)
|
||||
*/
|
||||
private async pushNewMail(
|
||||
user: string,
|
||||
afterCatchUp: CatchUpPushed | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const targets = await this.registrations.targetsOf(user);
|
||||
if (targets.length === 0) return;
|
||||
const countedAt = new Date();
|
||||
const counts = afterCatchUp
|
||||
? await this.freshCounts(user, targets, 0, afterCatchUp)
|
||||
: await this.provider.countInboxUnreadSince(
|
||||
user,
|
||||
targets.map((target) => target.seenAt),
|
||||
);
|
||||
await this.deliver(user, targets, counts, countedAt);
|
||||
} catch (error) {
|
||||
this.reportFailure(user, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 따라잡기 — 그 전 CATCH_UP_WINDOW_MS 안에 온 · 마지막으로 보낸 뒤 · 본 뒤의 안 읽은 메일이 있는 기기에 그때의 N 으로 한 번 (던지지 않는다).
|
||||
* 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만).
|
||||
* **새로 온 것은 본 때 · 마지막으로 보낸 때의 다음 초부터 본다**(nextSecondOf) — 세기는 같은 초를 넣으므로, 그대로면 그 초에 받은
|
||||
* (이미 보았거나 그 알림의 N 에 이미 든) 메일이 "새로 온 것"으로 다시 세진다(2026-11-07 리뷰 M1). N 은 본 때의 초부터 그대로 센다(덜 세지 않게)
|
||||
*/
|
||||
private async catchUp(user: string): Promise<void> {
|
||||
try {
|
||||
// 붙잡은 뒤 새 메일 신호를 이미 받았다 — 신호 쪽이 그때의 N 으로 보낸다(같은 메일로 두 번 울리지 않게 — 리뷰 N1)
|
||||
if (this.signaledSinceHold.has(user)) return;
|
||||
const targets = await this.registrations.targetsOf(user);
|
||||
if (targets.length === 0) return;
|
||||
const countedAt = new Date();
|
||||
const counts = await this.freshCounts(
|
||||
user,
|
||||
targets,
|
||||
countedAt.getTime() - CATCH_UP_WINDOW_MS,
|
||||
);
|
||||
// 세는 사이 신호가 왔다 — 위와 같다
|
||||
if (this.signaledSinceHold.has(user)) return;
|
||||
if (counts.every((count) => count < 1)) return;
|
||||
// 따라잡기도 간격에 든다 — 바로 뒤의 새 메일은 간격 끝에 한 번, 그 보내기는 이 따라잡기가 센 뒤 새로 받은 메일이 있는 기기에만.
|
||||
// 보내는 동안의 신호도 그 규칙을 타게 보낼 기기 모두로 먼저 적고, 보낸 뒤 실제로 보낸 기기로 좁힌다(그사이 보내기가 가져갔으면 그대로)
|
||||
const pacing = this.pacingOf(user);
|
||||
pacing.lastFlushAt = Date.now();
|
||||
const planned: CatchUpPushed = {
|
||||
sessions: new Set(
|
||||
targets
|
||||
.filter((_, index) => (counts[index] ?? 0) >= 1)
|
||||
.map((target) => target.sessionId),
|
||||
),
|
||||
countedAt,
|
||||
};
|
||||
pacing.afterCatchUp = planned;
|
||||
const pushed = await this.deliver(user, targets, counts, countedAt);
|
||||
if (this.pacing.get(user) === pacing && pacing.afterCatchUp === planned) {
|
||||
pacing.afterCatchUp =
|
||||
pushed.length > 0 ? { sessions: new Set(pushed), countedAt } : null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.reportFailure(user, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기기마다 N — 단 "새로 온 것"(본 때 · 마지막으로 보낸 때의 다음 초부터, `windowStart` 뒤 — 따라잡기가 보낸 기기는 그 센 때의 다음 초부터도)이
|
||||
* 없는 기기는 0 (따라잡기 · 따라잡기 뒤 첫 보내기). 엔진에는 기기마다 두 시각(새로 온 것을 볼 시작 · N 의 시작)을 한 번에 묻는다(같은 시각은 한 번만)
|
||||
*/
|
||||
private async freshCounts(
|
||||
user: string,
|
||||
targets: readonly PushTarget[],
|
||||
windowStart: number,
|
||||
afterCatchUp: CatchUpPushed | null = null,
|
||||
): Promise<number[]> {
|
||||
const freshSince = targets.map((target) =>
|
||||
Math.max(
|
||||
nextSecondOf(target.seenAt),
|
||||
nextSecondOf(target.lastPushedAt),
|
||||
afterCatchUp?.sessions.has(target.sessionId)
|
||||
? nextSecondOf(afterCatchUp.countedAt)
|
||||
: 0,
|
||||
windowStart,
|
||||
),
|
||||
);
|
||||
const times = [
|
||||
...new Set([
|
||||
...freshSince,
|
||||
...targets.map((target) => target.seenAt.getTime()),
|
||||
]),
|
||||
];
|
||||
const counted = await this.provider.countInboxUnreadSince(
|
||||
user,
|
||||
times.map((time) => new Date(time)),
|
||||
);
|
||||
const countAt = new Map(
|
||||
times.map((time, index) => [time, counted[index] ?? 0]),
|
||||
);
|
||||
return targets.map((target, index) =>
|
||||
(countAt.get(freshSince[index]) ?? 0) > 0
|
||||
? (countAt.get(target.seenAt.getTime()) ?? 0)
|
||||
: 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* N 이 1 이상인 기기에 보낸다 — 보낸 기기는 그 N 을 센 때를 적고, 받지 않는 기기는 등록을 지운다.
|
||||
* 보내기 직전(다시 하기마다) 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는지 다시 본다 — 그사이 그 휴대폰이 다른 직원에게 옮겨 갔으면 보내지 않는다
|
||||
*/
|
||||
private async deliver(
|
||||
user: string,
|
||||
targets: readonly PushTarget[],
|
||||
counts: readonly number[],
|
||||
countedAt: Date,
|
||||
): Promise<string[]> {
|
||||
const pushed: string[] = [];
|
||||
const pushedCounts: number[] = [];
|
||||
await Promise.all(
|
||||
targets.map(async (target, index) => {
|
||||
const count = counts[index] ?? 0;
|
||||
if (count < 1) return;
|
||||
const outcome = await this.sender.send(
|
||||
target.fid,
|
||||
{ kind: 'new-mail', count },
|
||||
{
|
||||
stillValid: () =>
|
||||
this.registrations.isDeliverable(target.sessionId, target.fid),
|
||||
},
|
||||
);
|
||||
if (outcome === 'sent') {
|
||||
pushed.push(target.sessionId);
|
||||
pushedCounts.push(count);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'unregistered') {
|
||||
await this.forget(user, target);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (pushed.length === 0) return pushed;
|
||||
await this.registrations.recordPushed(pushed, countedAt);
|
||||
this.logger.debug(
|
||||
`앱 알림: user=${maskEmail(user)}, 기기 ${pushed.length}, N ${pushedCounts.join('·')}`,
|
||||
);
|
||||
return pushed;
|
||||
}
|
||||
|
||||
private async forget(user: string, target: PushTarget): Promise<void> {
|
||||
try {
|
||||
await this.registrations.forgetDevice(target.sessionId, target.fid);
|
||||
} catch (error) {
|
||||
this.reportFailure(user, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** 보내지 못함 — warn (서버가 닫히는 중이면 남기지 않는다 — 그때의 실패는 종료 때문이다) */
|
||||
private reportFailure(user: string, error: unknown): void {
|
||||
if (this.stopping) return;
|
||||
this.logger.warn(
|
||||
`[${codeOf(error)}] 앱 새 메일 알림을 보내지 못했습니다: user=${maskEmail(user)}, reason=${failureSummary(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export class AccountCommands {
|
||||
expired
|
||||
? `1. 앱 비밀번호 — 건너뜀(비밀번호가 이미 만료된 계정이라 엔진이 막는다)${employee.appPasswords > 0 ? ` · ${employee.appPasswords}개가 남아 있다 — 직원이 새 비밀번호를 정한 뒤 다시 재설정` : ''}`
|
||||
: `1. 앱 비밀번호 ${employee.appPasswords}개 지우기 (메일 프로그램은 새 앱 비밀번호로 다시 연결)`,
|
||||
'2. 웹메일 로그인 모두 끊기',
|
||||
'2. 웹메일 · 앱 로그인 모두 끊기',
|
||||
expired
|
||||
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
|
||||
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
|
||||
@@ -318,13 +318,13 @@ export class AccountCommands {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
} catch (error) {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`,
|
||||
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`,
|
||||
);
|
||||
await fail(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
return;
|
||||
@@ -367,7 +367,7 @@ export class AccountCommands {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`비밀번호를 바꿨는지 모릅니다(웹메일 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`,
|
||||
`비밀번호를 바꿨는지 모릅니다(웹메일 · 앱 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`,
|
||||
);
|
||||
await fail(`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`);
|
||||
return;
|
||||
@@ -375,7 +375,7 @@ export class AccountCommands {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`비밀번호를 바꾸지 못했습니다(웹메일 로그인은 끊었습니다). ${failureLine(error)}`,
|
||||
`비밀번호를 바꾸지 못했습니다(웹메일 · 앱 로그인은 끊었습니다). ${failureLine(error)}`,
|
||||
);
|
||||
if (noticeId !== null) {
|
||||
const removed = await this.systemMail.remove(employee.email, noticeId);
|
||||
@@ -397,7 +397,11 @@ export class AccountCommands {
|
||||
const late = await this.data.revokeSessions(employee.email);
|
||||
ended += late;
|
||||
if (late > 0) {
|
||||
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
@@ -440,7 +444,7 @@ export class AccountCommands {
|
||||
employee.passwordExpired
|
||||
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
|
||||
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
|
||||
' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저',
|
||||
' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 기기',
|
||||
...(requirement.isRequired
|
||||
? [
|
||||
' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다',
|
||||
|
||||
@@ -67,7 +67,7 @@ export function employeeLine(
|
||||
return [
|
||||
`${employee.email} (${employee.name})`,
|
||||
` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`,
|
||||
` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`,
|
||||
` 마지막 로그인(웹메일 · 앱) ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -329,14 +329,14 @@ export class AdminCommands {
|
||||
}
|
||||
}
|
||||
|
||||
/** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 (읽기만 — 기록하지 않는다) */
|
||||
/** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 로그인(웹메일 · 앱) · 메일함 사용량 (읽기만 — 기록하지 않는다) */
|
||||
async status(io: OpsIo): Promise<void> {
|
||||
try {
|
||||
const [employees, settings] = await Promise.all([
|
||||
this.engine.listEmployees(),
|
||||
this.settings.reload(),
|
||||
]);
|
||||
const lastLogins = await this.data.lastWebmailLogins(
|
||||
const lastLogins = await this.data.lastSignIns(
|
||||
employees.map((employee) => employee.email),
|
||||
);
|
||||
// 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다
|
||||
|
||||
@@ -54,6 +54,7 @@ const ROW_LABELS: Record<keyof AddressRows, string> = {
|
||||
shareLinks: '대용량 첨부 링크',
|
||||
searchStates: '검색 색인 상태',
|
||||
searchMessages: '검색 색인(메일)',
|
||||
pushRegistrations: '앱 알림 등록',
|
||||
};
|
||||
|
||||
/** 남은 줄을 보이는 글 */
|
||||
@@ -94,7 +95,7 @@ function footprintLines(footprint: AddressFootprint, first: number): string[] {
|
||||
`${first}. 우리 DB 의 그 주소 기록 지우기 — 같은 주소를 다시 쓸 때 섞이지 않게`,
|
||||
...rowsLines(footprint.rows),
|
||||
...(footprint.exempt ? [' 2단계 인증 필수의 예외 주소에서 빼기'] : []),
|
||||
' 웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다',
|
||||
' 웹메일 · 앱 세션은 끊긴 채 두었다가 정리 작업이 지운다',
|
||||
];
|
||||
if (footprint.lists.length > 0) {
|
||||
lines.push(
|
||||
@@ -168,7 +169,7 @@ export class LockCommands {
|
||||
? [`2. 자동 응답 — 제목 「${rules.autoReply.subject}」`]
|
||||
: []),
|
||||
'3. 로그인 권한 빼기 — 웹메일 · 메일 프로그램 · 앱 비밀번호가 모두 막힌다. 메일은 계속 받는다',
|
||||
'4. 웹메일 로그인 모두 끊기',
|
||||
'4. 웹메일 · 앱 로그인 모두 끊기',
|
||||
...(revokeLinks ? ['5. 보낸 대용량 첨부 공개 링크 모두 끊기'] : []),
|
||||
];
|
||||
if (!(await confirmPlan(io, '잠금', lines))) return;
|
||||
@@ -245,12 +246,16 @@ export class LockCommands {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
step(io, false, `웹메일 로그인을 끊지 못했습니다. ${failureLine(error)}`);
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 · 앱 로그인을 끊지 못했습니다. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
if (revokeLinks) {
|
||||
@@ -290,11 +295,15 @@ export class LockCommands {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`,
|
||||
`웹메일 · 앱 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`,
|
||||
);
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
} catch (error) {
|
||||
step(io, false, `웹메일 로그인도 끊지 못했습니다. ${failureLine(error)}`);
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 · 앱 로그인도 끊지 못했습니다. ${failureLine(error)}`,
|
||||
);
|
||||
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -187,7 +187,7 @@ function renewPlan(
|
||||
? `${employee.name} <${employee.email}> — 지금 이전용 ~${untilText(employee)}`
|
||||
: `${employee.name} <${employee.email}> — 비밀번호가 만료된 계정`,
|
||||
),
|
||||
'1. 웹메일 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)',
|
||||
'1. 웹메일 · 앱 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)',
|
||||
sessionHasPassword
|
||||
? '2. 이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 건다 — 다시 보이지 않는다'
|
||||
: '2. 이전용 비밀번호를 무작위로 만들어 건다 — 만든 뒤 한 번만 보인다. 이번 실행에서는 모두 이 값을 쓴다',
|
||||
@@ -224,12 +224,12 @@ function finishPlan(
|
||||
: single.appPasswords > 0
|
||||
? `1. 앱 비밀번호 ${single.appPasswords}개 지우기`
|
||||
: '1. 앱 비밀번호 — 없다',
|
||||
'2. 웹메일 로그인 모두 끊기 (확인하려 들어간 로그인 포함)',
|
||||
'2. 웹메일 · 앱 로그인 모두 끊기 (확인하려 들어간 로그인 포함)',
|
||||
single === null
|
||||
? '3. 이전 중 로그인 기록 지우기(위의 줄 수 — 웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게'
|
||||
: `3. 이전 중 로그인 기록 ${events(single)}줄 지우기(웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게`,
|
||||
'4. 직원용 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다',
|
||||
'5. 그사이 새로 로그인한 웹메일도 끊기',
|
||||
'5. 그사이 새로 로그인한 웹메일 · 앱도 끊기',
|
||||
' 받은편지함 알림 메일은 넣지 않는다',
|
||||
...(twoFactor.length > 0
|
||||
? [
|
||||
@@ -517,17 +517,17 @@ export class MigrationCommands {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
} catch (error) {
|
||||
const line = failureLine(error);
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
|
||||
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
|
||||
);
|
||||
return notDone(
|
||||
`웹메일 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`,
|
||||
`웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`,
|
||||
`${from} · 세션 끊기 실패 · ${failureDetail(error)}`,
|
||||
);
|
||||
}
|
||||
@@ -581,7 +581,11 @@ export class MigrationCommands {
|
||||
const late = await this.data.revokeSessions(employee.email);
|
||||
ended += late;
|
||||
if (late > 0) {
|
||||
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
@@ -589,10 +593,10 @@ export class MigrationCommands {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
|
||||
`그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다. ${line}`,
|
||||
);
|
||||
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
|
||||
problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
|
||||
problem = `그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다 — ${line}`;
|
||||
}
|
||||
await record(
|
||||
result,
|
||||
@@ -712,17 +716,17 @@ export class MigrationCommands {
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
|
||||
);
|
||||
} catch (error) {
|
||||
const line = failureLine(error);
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
|
||||
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
|
||||
);
|
||||
return fail(
|
||||
`웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
|
||||
`웹메일 · 앱 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
|
||||
`세션 끊기 실패 · ${failureDetail(error)}`,
|
||||
);
|
||||
}
|
||||
@@ -780,13 +784,17 @@ export class MigrationCommands {
|
||||
'직원용 임시 비밀번호와 만료를 걸었습니다 — 이전용 비밀번호는 더 쓰지 못합니다.',
|
||||
);
|
||||
|
||||
// ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일
|
||||
// ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일 · 앱
|
||||
let result: 'success' | 'partial' = 'success';
|
||||
try {
|
||||
const late = await this.data.revokeSessions(employee.email);
|
||||
ended += late;
|
||||
if (late > 0) {
|
||||
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
|
||||
step(
|
||||
io,
|
||||
true,
|
||||
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
result = 'partial';
|
||||
@@ -794,10 +802,10 @@ export class MigrationCommands {
|
||||
step(
|
||||
io,
|
||||
false,
|
||||
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
|
||||
`그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다. ${line}`,
|
||||
);
|
||||
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
|
||||
row.problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
|
||||
row.problem = `그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다 — ${line}`;
|
||||
}
|
||||
notes.push(`세션 ${ended}개 끊음`);
|
||||
await this.audit.record({
|
||||
|
||||
@@ -57,6 +57,7 @@ const NO_ROWS: AddressRows = {
|
||||
shareLinks: 0,
|
||||
searchStates: 0,
|
||||
searchMessages: 0,
|
||||
pushRegistrations: 0,
|
||||
};
|
||||
|
||||
function employee(overrides: Partial<EngineEmployee> = {}): EngineEmployee {
|
||||
@@ -312,7 +313,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
isTwoFactorExempt: jest.Mock;
|
||||
changeTwoFactorExempt: jest.Mock;
|
||||
purgeAddress: jest.Mock;
|
||||
lastWebmailLogins: jest.Mock;
|
||||
lastSignIns: jest.Mock;
|
||||
deleteLoginEventsOf: jest.Mock;
|
||||
countLoginEventsOf: jest.Mock;
|
||||
lastPasswordSetIsMigration: jest.Mock;
|
||||
@@ -427,7 +428,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
filesLeftForCleanup: 0,
|
||||
});
|
||||
}),
|
||||
lastWebmailLogins: jest.fn().mockResolvedValue(new Map()),
|
||||
lastSignIns: jest.fn().mockResolvedValue(new Map()),
|
||||
deleteLoginEventsOf: jest.fn((email: string) => {
|
||||
engine.calls.push(`deleteLoginEvents:${email}`);
|
||||
return Promise.resolve(12);
|
||||
@@ -613,7 +614,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
1,
|
||||
);
|
||||
expect(io.output).toContain(
|
||||
'그사이 새로 로그인한 웹메일 1개도 끊었습니다',
|
||||
'그사이 새로 로그인한 웹메일 · 앱 1개도 끊었습니다',
|
||||
);
|
||||
expect(audit.entries[0]).toMatchObject({
|
||||
command: 'password-reset',
|
||||
@@ -1008,7 +1009,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
`공용 주소 1개의 구성원에서 빼기 — info@${DOMAIN}`,
|
||||
);
|
||||
expect(no.output).toContain(
|
||||
'웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다',
|
||||
'웹메일 · 앱 세션은 끊긴 채 두었다가 정리 작업이 지운다',
|
||||
);
|
||||
expect(order()).toEqual([`find:${ARA}`]);
|
||||
|
||||
@@ -1106,6 +1107,18 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]);
|
||||
});
|
||||
|
||||
it('앱 알림 등록(휴대폰 주소)도 그 주소의 기록으로 보이고 지운다 (앱 1단계)', async () => {
|
||||
engine.employees.delete(ARA);
|
||||
data.countRowsOf.mockResolvedValueOnce({
|
||||
...NO_ROWS,
|
||||
pushRegistrations: 2,
|
||||
});
|
||||
const io = new ScriptedIo(['ara.kim', 'y', ARA]);
|
||||
await locks.offboard(io, OPERATOR);
|
||||
expect(io.output).toContain('앱 알림 등록 2줄');
|
||||
expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]);
|
||||
});
|
||||
|
||||
it('엔진 계정도 남은 것도 없으면 할 것이 없다 — 예외 주소만 남았어도 지운다', async () => {
|
||||
engine.employees.delete(ARA);
|
||||
const nothing = new ScriptedIo(['ara.kim']);
|
||||
@@ -1252,13 +1265,15 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
});
|
||||
|
||||
it('현황 — 직원마다 한 줄 묶음과 정책 · 연락처 (읽기만 — 기록하지 않는다)', async () => {
|
||||
data.lastWebmailLogins.mockResolvedValueOnce(
|
||||
data.lastSignIns.mockResolvedValueOnce(
|
||||
new Map([[ARA, new Date('2026-10-30T06:20:00Z')]]),
|
||||
);
|
||||
const io = new ScriptedIo([]);
|
||||
await admin.status(io);
|
||||
expect(io.output).toContain(`${ARA} (김아라)`);
|
||||
expect(io.output).toContain('2026년 10월 30일 (금) 오후 3:20');
|
||||
expect(io.output).toContain(
|
||||
'마지막 로그인(웹메일 · 앱) 2026년 10월 30일 (금) 오후 3:20',
|
||||
);
|
||||
expect(io.output).toContain('2단계 인증 필수 정책 꺼짐');
|
||||
expect(audit.entries).toEqual([]);
|
||||
});
|
||||
@@ -1513,9 +1528,9 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
expect(io.output).toContain(
|
||||
'지금 이전용 ~2026년 12월 1일 (화) 오후 3:00',
|
||||
);
|
||||
expect(io.output).toContain('1. 웹메일 로그인 모두 끊기');
|
||||
expect(io.output).toContain('1. 웹메일 · 앱 로그인 모두 끊기');
|
||||
expect(io.output).toContain(
|
||||
'그사이 새로 로그인한 웹메일 1개도 끊었습니다',
|
||||
'그사이 새로 로그인한 웹메일 · 앱 1개도 끊었습니다',
|
||||
);
|
||||
expect(linesWith(io, password)).toBe(1);
|
||||
expect(audit.entries).toEqual([
|
||||
@@ -1541,7 +1556,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
await migration.renewPasswords(io, OPERATOR);
|
||||
expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]);
|
||||
expect(io.output).toContain(
|
||||
'웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다',
|
||||
'웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다',
|
||||
);
|
||||
expect(audit.entries).toHaveLength(1);
|
||||
expect(audit.entries[0]).toMatchObject({
|
||||
@@ -1565,7 +1580,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
).toEqual(['setMigrationPassword:acc-kim']);
|
||||
expect(io.output).toContain('걸었음 1명 · 못 함 1명');
|
||||
expect(io.output).toContain(
|
||||
`${HONG} (홍길동) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`,
|
||||
`${HONG} (홍길동) — [못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로)`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1846,7 +1861,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]);
|
||||
expect(engine.resetPasswords).toEqual([]);
|
||||
expect(sessions.output).toContain(
|
||||
'[못 함] 웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로)',
|
||||
'[못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(이전용 비밀번호 그대로)',
|
||||
);
|
||||
expect(audit.entries.map((entry) => entry.result)).toEqual([
|
||||
'failure',
|
||||
@@ -1867,7 +1882,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
const [password] = engine.resetPasswords;
|
||||
expect(linesWith(io, password)).toBe(1);
|
||||
expect(io.output).toContain(
|
||||
'참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다',
|
||||
'참고: 그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다',
|
||||
);
|
||||
expect(io.output).toContain('직원용 임시 비밀번호 1명 · 못 함 0명');
|
||||
expect(audit.entries[0]).toMatchObject({ result: 'partial' });
|
||||
@@ -2547,10 +2562,10 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
|
||||
).toEqual(['setMigrationPassword:acc-hong']);
|
||||
expect(io.output).toContain('걸었음 1명 · 못 함 1명');
|
||||
expect(io.output).toContain(
|
||||
`${HONG} (홍길동) — 참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다`,
|
||||
`${HONG} (홍길동) — 참고: 그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다`,
|
||||
);
|
||||
expect(io.output).toContain(
|
||||
`${KIM} (김철수) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`,
|
||||
`${KIM} (김철수) — [못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로)`,
|
||||
);
|
||||
expect(io.output).toContain(
|
||||
'못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.',
|
||||
|
||||
@@ -3,7 +3,10 @@ import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { type DataSource, type EntityManager, In } from 'typeorm';
|
||||
|
||||
import { AppPasswordUsageEntity } from '@/modules/account-security/app-password-usage.entity';
|
||||
import { LoginEventEntity } from '@/modules/account-security/login-event.entity';
|
||||
import {
|
||||
LoginEventEntity,
|
||||
type SignInMethod,
|
||||
} from '@/modules/account-security/login-event.entity';
|
||||
import { TwoFactorSettingEntity } from '@/modules/account-security/two-factor-setting.entity';
|
||||
import { AuthSessionRepository } from '@/modules/auth/auth-session.repository';
|
||||
import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity';
|
||||
@@ -19,6 +22,7 @@ import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signatur
|
||||
import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity';
|
||||
import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage';
|
||||
import { MailUserSettingsEntity } from '@/modules/mail-user-settings/mail-user-settings.entity';
|
||||
import { PushRegistrationEntity } from '@/modules/push/push-registration.entity';
|
||||
import {
|
||||
SYSTEM_SETTINGS_ROW_ID,
|
||||
SystemSettingsEntity,
|
||||
@@ -34,7 +38,7 @@ import {
|
||||
|
||||
/**
|
||||
* 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인).
|
||||
* 웹메일 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절).
|
||||
* 웹메일 · 앱 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절).
|
||||
* 끊긴 행은 로그인된 브라우저 목록에 보이지 않고 쓸 수도 없다
|
||||
*/
|
||||
export interface AddressRows {
|
||||
@@ -50,6 +54,8 @@ export interface AddressRows {
|
||||
/** 14단계 — 검색 색인의 상태 줄 · 메일 줄 (메일 글이 한 벌 더 든다) */
|
||||
readonly searchStates: number;
|
||||
readonly searchMessages: number;
|
||||
/** 앱 1단계 — 앱 알림 등록(휴대폰 주소). 세션을 끊으면 보내지 않지만, 같은 주소의 새 사람에게 옛 휴대폰이 남지 않게 지운다 */
|
||||
readonly pushRegistrations: number;
|
||||
}
|
||||
|
||||
/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */
|
||||
@@ -127,7 +133,7 @@ export function exemptWithout(
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리 ·
|
||||
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 로그인(웹메일 · 앱) · 퇴사 마무리의 주소 정리 ·
|
||||
* 메일 이전 마치기의 이전 중 로그인 기록 지우기.
|
||||
* 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로
|
||||
* 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다.
|
||||
@@ -144,7 +150,7 @@ export class OpsDataService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 그 직원의 웹메일 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts)
|
||||
* 그 직원의 웹메일 · 앱 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts — Bearer 도)
|
||||
*/
|
||||
revokeSessions(email: string): Promise<number> {
|
||||
return this.sessions.revokeAllOf(
|
||||
@@ -162,16 +168,22 @@ export class OpsDataService {
|
||||
});
|
||||
}
|
||||
|
||||
/** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */
|
||||
async lastWebmailLogins(
|
||||
emails: readonly string[],
|
||||
): Promise<Map<string, Date>> {
|
||||
/**
|
||||
* 마지막으로 웹메일 · 앱에 로그인한 때 (성공한 웹메일 · 앱 로그인 기록 — 90일 보존 안). 현황에만 쓴다 —
|
||||
* 앱만 쓰는 직원이 로그인한 적 없어 보이지 않게 앱(계약 1절 "앱 로그인")도 센다. 메일 프로그램(IMAP · SMTP)은 세지 않는다
|
||||
*/
|
||||
async lastSignIns(emails: readonly string[]): Promise<Map<string, Date>> {
|
||||
if (emails.length === 0) return new Map();
|
||||
const methods: SignInMethod[] = ['webmail', 'app'];
|
||||
const rows = await this.dataSource.manager
|
||||
.createQueryBuilder(LoginEventEntity, 'event')
|
||||
.select('event.user_email', 'email')
|
||||
.addSelect('MAX(event.occurred_at)', 'last')
|
||||
.where({ userEmail: In([...emails]), method: 'webmail', isSuccess: true })
|
||||
.where({
|
||||
userEmail: In([...emails]),
|
||||
method: In(methods),
|
||||
isSuccess: true,
|
||||
})
|
||||
.groupBy('event.user_email')
|
||||
.getRawMany<{ email: string; last: Date | string }>();
|
||||
return new Map(
|
||||
@@ -251,7 +263,7 @@ export class OpsDataService {
|
||||
|
||||
/**
|
||||
* 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다
|
||||
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 + 2단계 필수의 예외 주소에서 빼기).
|
||||
* (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 · 검색 색인 · 앱 알림 등록 + 2단계 필수의 예외 주소에서 빼기).
|
||||
* 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다
|
||||
*/
|
||||
async purgeAddress(email: string): Promise<AddressPurge> {
|
||||
@@ -376,7 +388,8 @@ export class OpsDataService {
|
||||
| typeof StoredFileEntity
|
||||
| typeof ShareLinkEntity
|
||||
| typeof MailSearchStateEntity
|
||||
| typeof MailSearchMessageEntity,
|
||||
| typeof MailSearchMessageEntity
|
||||
| typeof PushRegistrationEntity,
|
||||
where: Record<string, string>,
|
||||
): Promise<number> => {
|
||||
if (mode === 'count') return manager.countBy(entity, where);
|
||||
@@ -404,6 +417,10 @@ export class OpsDataService {
|
||||
// 메일 줄을 되살리지 않는다 (지우기는 잠금이 풀릴 때까지 기다리고, 다음 문장은 그 쓰기가 넣은 줄까지 본다)
|
||||
searchStates: await run(MailSearchStateEntity, { userEmail: email }),
|
||||
searchMessages: await run(MailSearchMessageEntity, { userEmail: email }),
|
||||
// 앱 1단계 — 세션은 위에서 끊었다(서버는 보낼 때 세션을 보므로 이미 보내지 않는다). 휴대폰 주소를 남기지 않는다
|
||||
pushRegistrations: await run(PushRegistrationEntity, {
|
||||
userEmail: email,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { testServiceAccount } from '@/../test/fcm-test-key';
|
||||
import { ConfiguredPushSender } from './configured-push-sender';
|
||||
import type { FcmTransport } from './fcm/fcm-transport';
|
||||
|
||||
const transport: FcmTransport = {
|
||||
requestToken: () => Promise.reject(new Error('보내지 않는다')),
|
||||
sendMessage: () => Promise.reject(new Error('보내지 않는다')),
|
||||
};
|
||||
|
||||
function configOf(values: Record<string, string | undefined>): ConfigService {
|
||||
return { get: (key: string) => values[key] } as unknown as ConfigService;
|
||||
}
|
||||
|
||||
describe('ConfiguredPushSender (PUSH_SENDER — env 의 FCM 키)', () => {
|
||||
let logs: { level: string; text: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
for (const level of ['error', 'warn', 'log'] as const) {
|
||||
jest
|
||||
.spyOn(Logger.prototype, level)
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
logs.push({ level, text: String(args[0]) });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('키가 있으면 보낼 수 있다 — 기동 로그는 프로젝트 id 한 줄(키 · 주소 · 개인 키 없음)', () => {
|
||||
const account = testServiceAccount();
|
||||
const sender = new ConfiguredPushSender(
|
||||
configOf({ FCM_SERVICE_ACCOUNT_KEY: account.base64 }),
|
||||
transport,
|
||||
);
|
||||
sender.onModuleInit();
|
||||
|
||||
expect(sender.available).toBe(true);
|
||||
expect(logs).toEqual([
|
||||
{
|
||||
level: 'log',
|
||||
text: '앱 새 메일 알림: 켬 (Firebase 프로젝트 arasystem-push-test)',
|
||||
},
|
||||
]);
|
||||
expect(logs[0].text).not.toContain('gserviceaccount');
|
||||
sender.beforeApplicationShutdown();
|
||||
});
|
||||
|
||||
it('키가 없으면 보내지 않는다(기동은 한다) — 운영은 warn, 개발은 log · send 는 failed', async () => {
|
||||
const production = new ConfiguredPushSender(
|
||||
configOf({ NODE_ENV: 'production', FCM_SERVICE_ACCOUNT_KEY: '' }),
|
||||
transport,
|
||||
);
|
||||
production.onModuleInit();
|
||||
const development = new ConfiguredPushSender(configOf({}), transport);
|
||||
development.onModuleInit();
|
||||
|
||||
expect(production.available).toBe(false);
|
||||
expect(logs.map(({ level }) => level)).toEqual(['warn', 'log']);
|
||||
expect(logs[0].text).toContain(
|
||||
'FCM 키(FCM_SERVICE_ACCOUNT_KEY)가 없습니다',
|
||||
);
|
||||
await expect(
|
||||
production.send('fid', { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
});
|
||||
|
||||
it('키의 모양이 틀리면(env 검사를 비켜 왔을 때) 끄고 error — 까닭만, 값은 싣지 않는다', () => {
|
||||
const broken = testServiceAccount({ type: 'authorized_user' }).base64;
|
||||
const sender = new ConfiguredPushSender(
|
||||
configOf({ FCM_SERVICE_ACCOUNT_KEY: broken }),
|
||||
transport,
|
||||
);
|
||||
sender.onModuleInit();
|
||||
|
||||
expect(sender.available).toBe(false);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].level).toBe('error');
|
||||
expect(logs[0].text).toContain('type 이 service_account 가 아닙니다');
|
||||
expect(logs[0].text).not.toContain(broken.slice(0, 24));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
type BeforeApplicationShutdown,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import {
|
||||
type FcmKeyReading,
|
||||
readFcmServiceAccountKey,
|
||||
} from './fcm/fcm-credentials';
|
||||
import { FcmPushSender } from './fcm/fcm-sender';
|
||||
import { FCM_TRANSPORT, type FcmTransport } from './fcm/fcm-transport';
|
||||
import { FCM_SERVICE_ACCOUNT_KEY_ENV } from './fcm/fcm.constants';
|
||||
import type {
|
||||
PushNotice,
|
||||
PushSendOptions,
|
||||
PushSendOutcome,
|
||||
PushSender,
|
||||
} from './push-sender.port';
|
||||
|
||||
/**
|
||||
* 앱 알림 보내기 (PUSH_SENDER) — env 의 FCM 키를 읽어 FCM 으로 보낸다. 키가 없으면 보내지 않는다(기동은 한다 — 계약 1절 "환경 변수").
|
||||
* 키의 모양이 틀리면 env 검사(env.validation.ts — 같은 함수)가 기동을 막으므로 여기까지 오지 않는다.
|
||||
* 기동 로그 한 줄: 켜짐(Firebase 프로젝트 id — 비밀이 아니다) · 꺼짐(운영은 warn). 서버 종료 때 진행 중인 보내기를 끊는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConfiguredPushSender
|
||||
implements PushSender, OnModuleInit, BeforeApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger('PushSender');
|
||||
private readonly reading: FcmKeyReading;
|
||||
private readonly fcm: FcmPushSender | null;
|
||||
private readonly isProduction: boolean;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
@Inject(FCM_TRANSPORT) transport: FcmTransport,
|
||||
) {
|
||||
this.reading = readFcmServiceAccountKey(
|
||||
config.get<string>(FCM_SERVICE_ACCOUNT_KEY_ENV),
|
||||
);
|
||||
this.isProduction = config.get<string>('NODE_ENV') === 'production';
|
||||
this.fcm =
|
||||
this.reading.status === 'valid'
|
||||
? new FcmPushSender(this.reading.credentials, transport)
|
||||
: null;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return this.fcm !== null;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.reading.status === 'valid') {
|
||||
this.logger.log(
|
||||
`앱 새 메일 알림: 켬 (Firebase 프로젝트 ${this.reading.credentials.projectId})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.reading.status === 'invalid') {
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 새 메일 알림을 끕니다. ${FCM_SERVICE_ACCOUNT_KEY_ENV} 를 읽지 못했습니다: ${this.reading.problem}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message = `앱 새 메일 알림을 끕니다. FCM 키(${FCM_SERVICE_ACCOUNT_KEY_ENV})가 없습니다.`;
|
||||
if (this.isProduction) this.logger.warn(message);
|
||||
else this.logger.log(message);
|
||||
}
|
||||
|
||||
send(
|
||||
fid: string,
|
||||
notice: PushNotice,
|
||||
options?: PushSendOptions,
|
||||
): Promise<PushSendOutcome> {
|
||||
return this.fcm
|
||||
? this.fcm.send(fid, notice, options)
|
||||
: Promise.resolve('failed');
|
||||
}
|
||||
|
||||
beforeApplicationShutdown(): void {
|
||||
this.fcm?.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { createValidationPipe } from '@/app.setup';
|
||||
import { PushRegistrationDto } from './push-registration.dto';
|
||||
|
||||
/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */
|
||||
async function through(body: unknown): Promise<object | 'VAL_001'> {
|
||||
try {
|
||||
return (await createValidationPipe().transform(body, {
|
||||
type: 'body',
|
||||
metatype: PushRegistrationDto,
|
||||
})) as object;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) return 'VAL_001';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const VALID = {
|
||||
fid: 'dQw4w9WgXcQ-ExampleFid',
|
||||
platform: 'android',
|
||||
appVersion: '1.0.0',
|
||||
};
|
||||
|
||||
describe('앱 알림 등록 본문 (PUT /api/push-registrations/current)', () => {
|
||||
it('fid · platform · appVersion 을 받는다', async () => {
|
||||
await expect(through(VALID)).resolves.toEqual(VALID);
|
||||
await expect(
|
||||
through({ ...VALID, fid: 'a'.repeat(64), appVersion: '1234.0.12' }),
|
||||
).resolves.toMatchObject({ appVersion: '1234.0.12' });
|
||||
});
|
||||
|
||||
it('fid — 글 · 64자 이하 · 영문 · 숫자 · - · _ 만 (등록 토큰의 : · 공백 · 빈 글은 400)', async () => {
|
||||
for (const fid of [
|
||||
'',
|
||||
'a'.repeat(65),
|
||||
'fGx:APA91bExample',
|
||||
'has space',
|
||||
'한글주소',
|
||||
12345,
|
||||
null,
|
||||
]) {
|
||||
await expect(through({ ...VALID, fid })).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('platform — android 만', async () => {
|
||||
for (const platform of ['ios', 'Android', '', null]) {
|
||||
await expect(through({ ...VALID, platform })).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('appVersion — 숫자 셋을 점으로(칸마다 1~4자리 · 앞자리 0 없이)', async () => {
|
||||
for (const appVersion of [
|
||||
'1.0',
|
||||
'1.0.0.1',
|
||||
'01.0.0',
|
||||
'1.0.0-beta',
|
||||
'12345.0.0',
|
||||
'v1.0.0',
|
||||
'',
|
||||
100,
|
||||
]) {
|
||||
await expect(through({ ...VALID, appVersion })).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('빠진 칸 · 모르는 칸(예전 계획의 token · isEnabled)은 400', async () => {
|
||||
for (const body of [
|
||||
{},
|
||||
{ fid: VALID.fid, platform: 'android' },
|
||||
{ ...VALID, token: 'x' },
|
||||
{ ...VALID, isEnabled: true },
|
||||
]) {
|
||||
await expect(through(body)).resolves.toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsString, Matches } from 'class-validator';
|
||||
|
||||
import { MaxCodeUnits } from '@/common/decorators/code-units.decorator';
|
||||
import {
|
||||
APP_VERSION_MAX_LENGTH,
|
||||
APP_VERSION_PATTERN,
|
||||
PUSH_FID_MAX_LENGTH,
|
||||
PUSH_FID_PATTERN,
|
||||
PUSH_PLATFORMS,
|
||||
type PushPlatform,
|
||||
} from '@/modules/push/push.constants';
|
||||
|
||||
/**
|
||||
* 앱 알림 등록의 본문 · 응답 (계약 1절 "앱 새 메일 알림 · 앱 설정" — `PUT /api/push-registrations/current`).
|
||||
* 칸의 검사는 applyDecorators 로 묶어 적은 차례대로 돈다 — 형식 → 길이 → 모양 (전역 파이프가 stopAtFirstError 라 긴 글은 길이에서 멈춘다 — contact.dto.ts 와 같은 결).
|
||||
* FID 는 검사 사유 · 로그에 남지 않는다(검사 실패의 응답은 VAL_001 하나 — 필터가 사유를 싣지 않는다)
|
||||
*/
|
||||
|
||||
const FID_MESSAGE =
|
||||
'휴대폰 주소(fid)는 영문 · 숫자 · - · _ 로 된 64자 이하 글이어야 합니다.';
|
||||
const PLATFORM_MESSAGE = `플랫폼(platform)은 ${PUSH_PLATFORMS.join(' · ')} 가운데 하나여야 합니다.`;
|
||||
const APP_VERSION_MESSAGE =
|
||||
'앱 판(appVersion)은 1.0.0 처럼 숫자 셋을 점으로 이은 글이어야 합니다.';
|
||||
|
||||
/** FID — 글 → 64자 → base64url 글자 */
|
||||
function FidField(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsString({ message: FID_MESSAGE }),
|
||||
MaxCodeUnits(PUSH_FID_MAX_LENGTH, { message: FID_MESSAGE }),
|
||||
Matches(PUSH_FID_PATTERN, { message: FID_MESSAGE }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 앱 판 — 글 → 14자 → 숫자.숫자.숫자 */
|
||||
function AppVersionField(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
IsString({ message: APP_VERSION_MESSAGE }),
|
||||
MaxCodeUnits(APP_VERSION_MAX_LENGTH, { message: APP_VERSION_MESSAGE }),
|
||||
Matches(APP_VERSION_PATTERN, { message: APP_VERSION_MESSAGE }),
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /api/push-registrations/current 의 본문 */
|
||||
export class PushRegistrationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'이 휴대폰의 주소 — Firebase 설치 id(FID — firebase_messaging 의 onRegistered 가 준 값). 영문 · 숫자 · `-` · `_` 64자 이하',
|
||||
example: 'dQw4w9WgXcQ-ExampleFid',
|
||||
maxLength: PUSH_FID_MAX_LENGTH,
|
||||
})
|
||||
@FidField()
|
||||
fid!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '플랫폼 — 지금은 android 만',
|
||||
enum: PUSH_PLATFORMS,
|
||||
example: 'android',
|
||||
})
|
||||
@IsIn(PUSH_PLATFORMS, { message: PLATFORM_MESSAGE })
|
||||
platform!: PushPlatform;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'앱 판 (PackageInfo.version) — 숫자.숫자.숫자, 칸마다 1~4자리 · 앞자리 0 없이',
|
||||
example: '1.0.0',
|
||||
maxLength: APP_VERSION_MAX_LENGTH,
|
||||
})
|
||||
@AppVersionField()
|
||||
appVersion!: string;
|
||||
}
|
||||
|
||||
/** PUT /api/push-registrations/current 의 응답 */
|
||||
export class PushRegistrationResultDto {
|
||||
@ApiProperty({
|
||||
description: '등록을 바꾼 때 (ISO 8601 UTC — 서버 시각)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2026-11-07T05:00:00.000Z',
|
||||
})
|
||||
updatedAt!: string;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { verify } from 'crypto';
|
||||
|
||||
import { testServiceAccount } from '@/../test/fcm-test-key';
|
||||
import {
|
||||
FcmAccessTokens,
|
||||
FcmAuthError,
|
||||
signServiceAccountAssertion,
|
||||
} from './fcm-auth';
|
||||
import {
|
||||
type FcmCredentials,
|
||||
readFcmServiceAccountKey,
|
||||
} from './fcm-credentials';
|
||||
import type { FcmHttpResponse, FcmTransport } from './fcm-transport';
|
||||
import { FCM_REQUEST_TIMEOUT_MS } from './fcm.constants';
|
||||
|
||||
const account = testServiceAccount();
|
||||
|
||||
function credentialsOf(): FcmCredentials {
|
||||
const reading = readFcmServiceAccountKey(account.base64);
|
||||
if (reading.status !== 'valid') throw new Error('시험 키를 읽지 못했다');
|
||||
return reading.credentials;
|
||||
}
|
||||
|
||||
const credentials = credentialsOf();
|
||||
const NOW = Date.parse('2026-11-07T05:00:00Z');
|
||||
|
||||
function decodePart(part: string): Record<string, unknown> {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
const grant = (token: string, expiresIn = 3599): FcmHttpResponse => ({
|
||||
status: 200,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({
|
||||
access_token: token,
|
||||
expires_in: expiresIn,
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
});
|
||||
|
||||
/** 토큰 교환 대역 — 준비한 답을 차례로, 받은 form 을 모은다 */
|
||||
function fakeTransport(answers: (FcmHttpResponse | Error | 'hang')[]): {
|
||||
transport: FcmTransport;
|
||||
forms: string[];
|
||||
signals: AbortSignal[];
|
||||
} {
|
||||
const forms: string[] = [];
|
||||
const signals: AbortSignal[] = [];
|
||||
const queue = [...answers];
|
||||
const transport: FcmTransport = {
|
||||
requestToken: (form, signal) => {
|
||||
forms.push(form);
|
||||
signals.push(signal);
|
||||
const next = queue.shift();
|
||||
if (next === undefined)
|
||||
return Promise.reject(new Error('준비한 답이 없다'));
|
||||
if (next === 'hang') {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
return next instanceof Error
|
||||
? Promise.reject(next)
|
||||
: Promise.resolve(next);
|
||||
},
|
||||
sendMessage: () => Promise.reject(new Error('토큰 교환만 시험한다')),
|
||||
};
|
||||
return { transport, forms, signals };
|
||||
}
|
||||
|
||||
async function authErrorOf(promise: Promise<unknown>): Promise<FcmAuthError> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof FcmAuthError) return error;
|
||||
throw error;
|
||||
}
|
||||
throw new Error('FcmAuthError 로 끝나야 한다');
|
||||
}
|
||||
|
||||
describe('signServiceAccountAssertion (서비스 계정 JWT — RS256)', () => {
|
||||
it('머리 kid = 키 id · alg RS256, 클레임 iss · scope · aud(Google 의 토큰 주소) · iat · exp(1시간) — 공개 키로 서명이 맞는다', () => {
|
||||
const jwt = signServiceAccountAssertion(credentials, 1_794_000_000);
|
||||
const [header, claims, signature] = jwt.split('.');
|
||||
|
||||
expect(decodePart(header)).toEqual({
|
||||
alg: 'RS256',
|
||||
typ: 'JWT',
|
||||
kid: '0123456789abcdef0123456789abcdef01234567',
|
||||
});
|
||||
expect(decodePart(claims)).toEqual({
|
||||
iss: 'arasystem-push@arasystem-push-test.iam.gserviceaccount.com',
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
iat: 1_794_000_000,
|
||||
exp: 1_794_003_600,
|
||||
});
|
||||
expect(
|
||||
verify(
|
||||
'sha256',
|
||||
Buffer.from(`${header}.${claims}`),
|
||||
account.publicKey,
|
||||
Buffer.from(signature, 'base64url'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FcmAccessTokens (OAuth access token — 기억 · 한 번만 교환)', () => {
|
||||
let now: number;
|
||||
const clock = () => now;
|
||||
const stop = new AbortController();
|
||||
|
||||
beforeEach(() => {
|
||||
now = NOW;
|
||||
});
|
||||
|
||||
it('JWT 승인으로 교환한다 — form 은 grant_type(jwt-bearer) · assertion(서비스 계정 JWT)', async () => {
|
||||
const fake = fakeTransport([grant('ya29.first')]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
await expect(tokens.get()).resolves.toBe('ya29.first');
|
||||
const form = new URLSearchParams(fake.forms[0]);
|
||||
expect(form.get('grant_type')).toBe(
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
);
|
||||
const claims = decodePart(form.get('assertion')?.split('.')[1] ?? '');
|
||||
expect(claims).toMatchObject({ iat: Math.floor(NOW / 1000) });
|
||||
});
|
||||
|
||||
it('만료 5분 전까지 기억한다 — 그 뒤에는 새로 교환', async () => {
|
||||
const fake = fakeTransport([
|
||||
grant('ya29.first', 3600),
|
||||
grant('ya29.second'),
|
||||
]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
await tokens.get();
|
||||
now = NOW + 54 * 60_000;
|
||||
await expect(tokens.get()).resolves.toBe('ya29.first');
|
||||
expect(fake.forms).toHaveLength(1);
|
||||
|
||||
now = NOW + 55 * 60_000;
|
||||
await expect(tokens.get()).resolves.toBe('ya29.second');
|
||||
expect(fake.forms).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('함께 부르면 교환은 한 번이다', async () => {
|
||||
const fake = fakeTransport([grant('ya29.shared')]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
const results = await Promise.all([
|
||||
tokens.get(),
|
||||
tokens.get(),
|
||||
tokens.get(),
|
||||
]);
|
||||
expect(results).toEqual(['ya29.shared', 'ya29.shared', 'ya29.shared']);
|
||||
expect(fake.forms).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('보내기가 401 을 받은 토큰을 버리면 다음에 새로 교환한다 — 그사이 바뀐 토큰이면 버리지 않는다', async () => {
|
||||
const fake = fakeTransport([grant('ya29.old'), grant('ya29.new')]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
await tokens.get();
|
||||
tokens.invalidate('ya29.other');
|
||||
await expect(tokens.get()).resolves.toBe('ya29.old');
|
||||
tokens.invalidate('ya29.old');
|
||||
await expect(tokens.get()).resolves.toBe('ya29.new');
|
||||
expect(fake.forms).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Google 이 거절하면 rejected — OAuth 의 error 코드만 싣고 설명 글은 버린다', async () => {
|
||||
const fake = fakeTransport([
|
||||
{
|
||||
status: 400,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid JWT Signature. key-id 0123456789abcdef',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
const error = await authErrorOf(tokens.get());
|
||||
expect(error.kind).toBe('rejected');
|
||||
expect(error.reason).toBe('http-400/invalid_grant');
|
||||
expect(`${error.message} ${error.reason}`).not.toContain('Signature');
|
||||
});
|
||||
|
||||
it('잠깐의 장애는 unavailable — 5xx · 429 · 연결 오류 · 모양이 틀린 200 · 시간 초과', async () => {
|
||||
const tokens = (answer: FcmHttpResponse | Error) =>
|
||||
new FcmAccessTokens(
|
||||
credentials,
|
||||
fakeTransport([answer]).transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
for (const [answer, reason] of [
|
||||
[{ status: 503, retryAfter: null, body: '' }, 'http-503'],
|
||||
[{ status: 429, retryAfter: '10', body: '' }, 'http-429'],
|
||||
[new Error('ECONNRESET'), 'network'],
|
||||
[
|
||||
{
|
||||
status: 200,
|
||||
retryAfter: null,
|
||||
body: '{"access_token": "has space"}',
|
||||
},
|
||||
'malformed-response',
|
||||
],
|
||||
] as const) {
|
||||
const error = await authErrorOf(tokens(answer).get());
|
||||
expect([error.kind, error.reason]).toEqual(['unavailable', reason]);
|
||||
}
|
||||
|
||||
jest.useFakeTimers({
|
||||
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
|
||||
});
|
||||
try {
|
||||
const hanging = new FcmAccessTokens(
|
||||
credentials,
|
||||
fakeTransport(['hang']).transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
const pending = authErrorOf(hanging.get());
|
||||
await jest.advanceTimersByTimeAsync(FCM_REQUEST_TIMEOUT_MS);
|
||||
const error = await pending;
|
||||
expect([error.kind, error.reason]).toEqual(['unavailable', 'timeout']);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('실패한 교환은 기억하지 않는다 — 다음 get 이 다시 교환한다', async () => {
|
||||
const fake = fakeTransport([new Error('ECONNRESET'), grant('ya29.after')]);
|
||||
const tokens = new FcmAccessTokens(
|
||||
credentials,
|
||||
fake.transport,
|
||||
stop.signal,
|
||||
clock,
|
||||
);
|
||||
|
||||
await authErrorOf(tokens.get());
|
||||
await expect(tokens.get()).resolves.toBe('ya29.after');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { sign } from 'crypto';
|
||||
|
||||
import type { FcmCredentials } from './fcm-credentials';
|
||||
import { deadlineSignal } from './fcm-timeout';
|
||||
import type { FcmTransport } from './fcm-transport';
|
||||
import {
|
||||
FCM_ACCESS_TOKEN_MAX_LENGTH,
|
||||
FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS,
|
||||
FCM_ASSERTION_LIFETIME_SECONDS,
|
||||
FCM_OAUTH_TOKEN_URL,
|
||||
FCM_REQUEST_TIMEOUT_MS,
|
||||
FCM_SCOPE,
|
||||
} from './fcm.constants';
|
||||
|
||||
/**
|
||||
* FCM 보내기의 OAuth access token (서비스 계정 JWT → `POST https://oauth2.googleapis.com/token`) — 새 의존성 없이 node crypto 의 RS256.
|
||||
* - 받은 토큰은 만료 FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS 전까지 기억한다. 함께 부르면 교환은 한 번이다
|
||||
* - 보내기가 401 을 받으면 그 토큰을 버린다(invalidate) — 다음 get 이 새로 받는다
|
||||
* JWT · access token · 개인 키 · Google 의 오류 설명 글은 로그 · 오류에 싣지 않는다 — 실패는 종류(OAuth 의 error 코드 · 상태)만
|
||||
*/
|
||||
|
||||
/** OAuth 의 JWT 승인 방식 (RFC 7523) */
|
||||
const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
||||
/** OAuth 오류 코드의 모양 — `invalid_grant` 처럼 (그 밖의 글은 버린다) */
|
||||
const OAUTH_ERROR_CODE = /^[a-z_]{1,64}$/;
|
||||
/** access token 의 모양 — 공백 · 제어 문자 없는 글 */
|
||||
const ACCESS_TOKEN_PATTERN = /^[\x21-\x7e]+$/;
|
||||
|
||||
/**
|
||||
* 토큰을 받지 못함 — rejected: Google 이 거절했다(키를 지움 · 막음 · 서버 시계가 틀림 · 계정 권한 — 다시 해도 같다),
|
||||
* unavailable: 잠깐의 장애(429 · 5xx · 연결 오류 · 시간 초과 · 모양이 틀린 응답)
|
||||
*/
|
||||
export class FcmAuthError extends Error {
|
||||
constructor(
|
||||
readonly kind: 'rejected' | 'unavailable',
|
||||
/** 로그에 남길 종류 — 상태 · OAuth 오류 코드 (설명 글 없음) */
|
||||
readonly reason: string,
|
||||
) {
|
||||
super(`fcm-auth-${kind}`);
|
||||
this.name = 'FcmAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlJson(value: object): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 계정 JWT (RS256) — 머리 kid = 키 id, iss = 서비스 계정 주소, scope = 보내기, aud = 토큰 주소, iat · exp(1시간).
|
||||
* aud 는 늘 Google 의 토큰 주소다(시험의 가짜 서버로 보내도) — 키 파일의 token_uri 는 쓰지 않는다
|
||||
*/
|
||||
export function signServiceAccountAssertion(
|
||||
credentials: FcmCredentials,
|
||||
issuedAtSeconds: number,
|
||||
): string {
|
||||
const header = base64UrlJson({
|
||||
alg: 'RS256',
|
||||
typ: 'JWT',
|
||||
kid: credentials.privateKeyId,
|
||||
});
|
||||
const claims = base64UrlJson({
|
||||
iss: credentials.clientEmail,
|
||||
scope: FCM_SCOPE,
|
||||
aud: FCM_OAUTH_TOKEN_URL,
|
||||
iat: issuedAtSeconds,
|
||||
exp: issuedAtSeconds + FCM_ASSERTION_LIFETIME_SECONDS,
|
||||
});
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = sign(
|
||||
'sha256',
|
||||
Buffer.from(signingInput),
|
||||
credentials.privateKey,
|
||||
).toString('base64url');
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/** OAuth 오류 본문 `{ error, error_description }` 의 코드만 (설명 글은 버린다) */
|
||||
function oauthErrorCode(body: string): string | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
const code =
|
||||
typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as { error?: unknown }).error
|
||||
: undefined;
|
||||
return typeof code === 'string' && OAUTH_ERROR_CODE.test(code)
|
||||
? code
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 성공 본문 → { 토큰, 수명(초) } — 모양이 틀리면 null */
|
||||
function readGrant(body: string): { token: string; expiresIn: number } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as {
|
||||
access_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
};
|
||||
const token = parsed.access_token;
|
||||
const expiresIn = parsed.expires_in;
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
token.length > FCM_ACCESS_TOKEN_MAX_LENGTH ||
|
||||
!ACCESS_TOKEN_PATTERN.test(token) ||
|
||||
typeof expiresIn !== 'number' ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { token, expiresIn };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FcmAccessTokens {
|
||||
private cached: {
|
||||
readonly token: string;
|
||||
readonly refreshAt: number;
|
||||
} | null = null;
|
||||
private inflight: Promise<string> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly credentials: FcmCredentials,
|
||||
private readonly transport: FcmTransport,
|
||||
/** 서버 종료 — 진행 중인 교환을 끊는다 */
|
||||
private readonly stopSignal: AbortSignal,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
) {}
|
||||
|
||||
/** 쓸 수 있는 access token — 기억한 것이 있으면 그것, 없으면 교환(함께 부르면 한 번). 받지 못하면 FcmAuthError */
|
||||
get(): Promise<string> {
|
||||
if (this.cached && this.now() < this.cached.refreshAt) {
|
||||
return Promise.resolve(this.cached.token);
|
||||
}
|
||||
this.inflight ??= this.exchange().finally(() => {
|
||||
this.inflight = null;
|
||||
});
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
/** 보내기가 401 을 받은 토큰 — 지금 기억한 것이 그것이면 버린다 */
|
||||
invalidate(token: string): void {
|
||||
if (this.cached?.token === token) this.cached = null;
|
||||
}
|
||||
|
||||
private async exchange(): Promise<string> {
|
||||
const startedAt = this.now();
|
||||
const assertion = signServiceAccountAssertion(
|
||||
this.credentials,
|
||||
Math.floor(startedAt / 1000),
|
||||
);
|
||||
const form = new URLSearchParams({
|
||||
grant_type: JWT_BEARER_GRANT,
|
||||
assertion,
|
||||
}).toString();
|
||||
|
||||
const deadline = deadlineSignal(FCM_REQUEST_TIMEOUT_MS, this.stopSignal);
|
||||
let status: number;
|
||||
let body: string;
|
||||
try {
|
||||
({ status, body } = await this.transport.requestToken(
|
||||
form,
|
||||
deadline.signal,
|
||||
));
|
||||
} catch {
|
||||
throw new FcmAuthError(
|
||||
'unavailable',
|
||||
deadline.timedOut() ? 'timeout' : 'network',
|
||||
);
|
||||
} finally {
|
||||
deadline.release();
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const grant = readGrant(body);
|
||||
if (!grant) throw new FcmAuthError('unavailable', 'malformed-response');
|
||||
this.cached = {
|
||||
token: grant.token,
|
||||
refreshAt: Math.max(
|
||||
startedAt,
|
||||
startedAt +
|
||||
grant.expiresIn * 1000 -
|
||||
FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS,
|
||||
),
|
||||
};
|
||||
return grant.token;
|
||||
}
|
||||
if (status === 429 || status >= 500) {
|
||||
throw new FcmAuthError('unavailable', `http-${status}`);
|
||||
}
|
||||
const code = oauthErrorCode(body);
|
||||
throw new FcmAuthError(
|
||||
'rejected',
|
||||
code ? `http-${status}/${code}` : `http-${status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
|
||||
import { testServiceAccount, toEnvValue } from '@/../test/fcm-test-key';
|
||||
import { readFcmServiceAccountKey } from './fcm-credentials';
|
||||
import { FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH } from './fcm.constants';
|
||||
|
||||
/** 틀린 까닭 — 아니면 시험이 틀렸다 */
|
||||
function problemOf(raw: string | undefined): string {
|
||||
const reading = readFcmServiceAccountKey(raw);
|
||||
if (reading.status !== 'invalid') {
|
||||
throw new Error(`invalid 여야 한다 (지금 ${reading.status})`);
|
||||
}
|
||||
return reading.problem;
|
||||
}
|
||||
|
||||
describe('readFcmServiceAccountKey (FCM 서비스 계정 키 — env FCM_SERVICE_ACCOUNT_KEY)', () => {
|
||||
it('키 JSON 을 통째로 base64 한 줄 — 프로젝트 id · 서비스 계정 주소 · 키 id · RSA 개인 키를 읽는다 (앞뒤 공백은 지운다)', () => {
|
||||
const account = testServiceAccount();
|
||||
const reading = readFcmServiceAccountKey(` ${account.base64}\n`);
|
||||
|
||||
expect(reading.status).toBe('valid');
|
||||
if (reading.status !== 'valid') return;
|
||||
expect(reading.credentials).toMatchObject({
|
||||
projectId: 'arasystem-push-test',
|
||||
clientEmail: 'arasystem-push@arasystem-push-test.iam.gserviceaccount.com',
|
||||
privateKeyId: '0123456789abcdef0123456789abcdef01234567',
|
||||
});
|
||||
expect(reading.credentials.privateKey.asymmetricKeyType).toBe('rsa');
|
||||
});
|
||||
|
||||
it('비었으면 absent — 알림만 끈다(기동은 한다)', () => {
|
||||
for (const raw of [undefined, '', ' ']) {
|
||||
expect(readFcmServiceAccountKey(raw)).toEqual({ status: 'absent' });
|
||||
}
|
||||
});
|
||||
|
||||
it('base64 한 줄이 아니면 — 줄을 나눈 base64(-w0 없이) · 다른 글자 · 패딩이 틀린 길이', () => {
|
||||
const { base64 } = testServiceAccount();
|
||||
const wrapped = base64.replace(/(.{76})/g, '$1\n');
|
||||
for (const raw of [
|
||||
wrapped,
|
||||
'not*base64!',
|
||||
'abc',
|
||||
'{"type":"service_account"}',
|
||||
]) {
|
||||
expect(problemOf(raw)).toBe('base64 한 줄이 아닙니다');
|
||||
}
|
||||
});
|
||||
|
||||
it(`${FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH.toLocaleString('en-US')}자를 넘으면 읽지 않는다`, () => {
|
||||
expect(
|
||||
problemOf('A'.repeat(FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH + 4)),
|
||||
).toContain('넘습니다');
|
||||
});
|
||||
|
||||
it('JSON · 객체가 아니면', () => {
|
||||
expect(problemOf(Buffer.from('키가 아님').toString('base64'))).toBe(
|
||||
'base64 를 풀면 JSON 이 아닙니다',
|
||||
);
|
||||
expect(problemOf(toEnvValue(['service_account']))).toBe(
|
||||
'base64 를 풀면 JSON 객체가 아닙니다',
|
||||
);
|
||||
});
|
||||
|
||||
it('칸이 틀리면 그 칸의 이름만 알린다 — type · project_id · client_email · private_key_id · token_uri · private_key', () => {
|
||||
const cases: [Record<string, unknown>, string][] = [
|
||||
[{ type: 'authorized_user' }, 'type 이 service_account 가 아닙니다'],
|
||||
[
|
||||
{ project_id: 'Bad_Project' },
|
||||
'project_id 가 프로젝트 id 모양이 아닙니다',
|
||||
],
|
||||
[{ project_id: undefined }, 'project_id 가 프로젝트 id 모양이 아닙니다'],
|
||||
[
|
||||
{ client_email: 'someone@gmail.com' },
|
||||
'client_email 이 서비스 계정 주소(…@….iam.gserviceaccount.com)가 아닙니다',
|
||||
],
|
||||
[{ private_key_id: 'xyz' }, 'private_key_id 가 키 id 모양이 아닙니다'],
|
||||
[
|
||||
{ token_uri: 'https://evil.example/token' },
|
||||
'token_uri 가 https://oauth2.googleapis.com/token 이 아닙니다',
|
||||
],
|
||||
[{ private_key: undefined }, 'private_key 가 없습니다'],
|
||||
[
|
||||
{
|
||||
private_key:
|
||||
'-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n',
|
||||
},
|
||||
'private_key 가 PEM 개인 키가 아닙니다',
|
||||
],
|
||||
];
|
||||
for (const [overrides, problem] of cases) {
|
||||
expect(problemOf(testServiceAccount(overrides).base64)).toBe(problem);
|
||||
}
|
||||
});
|
||||
|
||||
it('token_uri 가 없어도 된다 — 주소는 서버의 상수를 쓴다', () => {
|
||||
expect(
|
||||
readFcmServiceAccountKey(
|
||||
testServiceAccount({ token_uri: undefined }).base64,
|
||||
).status,
|
||||
).toBe('valid');
|
||||
});
|
||||
|
||||
it('RSA 가 아니거나 2048비트보다 짧은 키는 받지 않는다', () => {
|
||||
const ec = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
|
||||
.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString();
|
||||
expect(problemOf(testServiceAccount({ private_key: ec }).base64)).toBe(
|
||||
'private_key 가 RSA 키가 아닙니다',
|
||||
);
|
||||
const short = generateKeyPairSync('rsa', { modulusLength: 1024 })
|
||||
.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString();
|
||||
expect(problemOf(testServiceAccount({ private_key: short }).base64)).toBe(
|
||||
'private_key 가 2048비트보다 짧습니다',
|
||||
);
|
||||
});
|
||||
|
||||
it('틀린 까닭에 키의 값이 실리지 않는다 — 개인 키 · 주소 · 키 id · base64 원문', () => {
|
||||
const account = testServiceAccount({ type: 'nope' });
|
||||
const problems = [
|
||||
problemOf(account.base64),
|
||||
problemOf(
|
||||
testServiceAccount({ client_email: 'leak.me@gmail.com' }).base64,
|
||||
),
|
||||
problemOf(testServiceAccount({ private_key_id: 'leak-me-id' }).base64),
|
||||
].join('\n');
|
||||
expect(problems).not.toContain('BEGIN');
|
||||
expect(problems).not.toContain('leak');
|
||||
expect(problems).not.toContain(account.base64.slice(0, 16));
|
||||
expect(problems).not.toContain('arasystem-push-test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createPrivateKey, type KeyObject } from 'crypto';
|
||||
|
||||
import {
|
||||
FCM_OAUTH_TOKEN_URL,
|
||||
FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH,
|
||||
} from './fcm.constants';
|
||||
|
||||
/**
|
||||
* FCM 서비스 계정 키 읽기 (계약 1절 "앱 새 메일 알림 · 앱 설정" — env `FCM_SERVICE_ACCOUNT_KEY`) — 순수 함수.
|
||||
* 값은 서비스 계정 JSON 키 파일을 통째로 base64 한 줄로 바꾼 것이다(`backend/.env.{env}` — JWT_SECRET 과 같은 자리).
|
||||
* env 검사(env.validation.ts)와 보내는 쪽(configured-push-sender.ts)이 이 함수 하나를 쓴다 — 규칙이 한 곳이다.
|
||||
* 틀린 까닭(problem)에는 어느 칸이 틀렸는지만 적는다 — 키의 값 · 개인 키는 어디에도 싣지 않는다
|
||||
*/
|
||||
|
||||
/** 보내기에 쓰는 키의 칸 */
|
||||
export interface FcmCredentials {
|
||||
/** Firebase(Google Cloud) 프로젝트 id — 보내기 주소에 들어간다. 비밀이 아니다(기동 로그에 남긴다) */
|
||||
readonly projectId: string;
|
||||
/** 서비스 계정 주소 — JWT 의 iss */
|
||||
readonly clientEmail: string;
|
||||
/** 키 id — JWT 머리의 kid */
|
||||
readonly privateKeyId: string;
|
||||
/** RSA 개인 키 */
|
||||
readonly privateKey: KeyObject;
|
||||
}
|
||||
|
||||
/** 키를 읽은 결과 — 없음(알림 끔) · 읽음 · 틀림(까닭 — 값은 싣지 않는다) */
|
||||
export type FcmKeyReading =
|
||||
| { readonly status: 'absent' }
|
||||
| { readonly status: 'valid'; readonly credentials: FcmCredentials }
|
||||
| { readonly status: 'invalid'; readonly problem: string };
|
||||
|
||||
/** 표준 base64 한 줄 (패딩 포함 — `base64 -w0` · PowerShell 의 ToBase64String) */
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
/** Google Cloud 프로젝트 id — 소문자로 시작하는 소문자 · 숫자 · `-` 6~30자, `-` 로 끝나지 않는다 */
|
||||
const PROJECT_ID_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/;
|
||||
/** 서비스 계정 주소 — `<이름>@<프로젝트>.iam.gserviceaccount.com` (Firebase 의 기본 계정 · 따로 만든 계정 모두 이 꼴) */
|
||||
const CLIENT_EMAIL_PATTERN =
|
||||
/^[a-z0-9][a-z0-9-]{0,62}@[a-z0-9][a-z0-9-]{0,62}\.iam\.gserviceaccount\.com$/;
|
||||
/** 키 id — 16진수 (Google 은 40자) */
|
||||
const PRIVATE_KEY_ID_PATTERN = /^[0-9a-fA-F]{16,128}$/;
|
||||
/** RSA 키의 최소 크기(비트) — Google 이 만드는 키는 2048 */
|
||||
const MIN_RSA_MODULUS_BITS = 2_048;
|
||||
|
||||
function invalid(problem: string): FcmKeyReading {
|
||||
return { status: 'invalid', problem };
|
||||
}
|
||||
|
||||
/** 칸 하나 — 글이 아니거나 비었으면 undefined */
|
||||
function textField(
|
||||
source: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = source[key];
|
||||
return typeof value === 'string' && value !== '' ? value : undefined;
|
||||
}
|
||||
|
||||
/** PEM 개인 키 → RSA KeyObject (아니면 까닭) */
|
||||
function readPrivateKey(pem: string): KeyObject | string {
|
||||
let key: KeyObject;
|
||||
try {
|
||||
key = createPrivateKey({ key: pem, format: 'pem' });
|
||||
} catch {
|
||||
return 'private_key 가 PEM 개인 키가 아닙니다';
|
||||
}
|
||||
if (key.asymmetricKeyType !== 'rsa') {
|
||||
return 'private_key 가 RSA 키가 아닙니다';
|
||||
}
|
||||
const bits = key.asymmetricKeyDetails?.modulusLength ?? 0;
|
||||
if (bits < MIN_RSA_MODULUS_BITS) {
|
||||
return `private_key 가 ${MIN_RSA_MODULUS_BITS}비트보다 짧습니다`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* env 값 → 키. 비었으면 absent(알림 끔 — 기동은 한다).
|
||||
* base64 → UTF-8 JSON → `type: service_account` · `project_id` · `client_email` · `private_key_id` · `private_key`(PEM · RSA 2048 이상) ·
|
||||
* `token_uri`(있으면 Google 의 토큰 주소와 같아야 — 다른 곳으로 키를 보내지 않는다)
|
||||
*/
|
||||
export function readFcmServiceAccountKey(
|
||||
raw: string | undefined,
|
||||
): FcmKeyReading {
|
||||
const value = raw?.trim() ?? '';
|
||||
if (value === '') return { status: 'absent' };
|
||||
if (value.length > FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH) {
|
||||
return invalid(
|
||||
`${FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH.toLocaleString('en-US')}자를 넘습니다`,
|
||||
);
|
||||
}
|
||||
if (!BASE64_PATTERN.test(value) || value.length % 4 !== 0) {
|
||||
return invalid('base64 한 줄이 아닙니다');
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(value, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return invalid('base64 를 풀면 JSON 이 아닙니다');
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return invalid('base64 를 풀면 JSON 객체가 아닙니다');
|
||||
}
|
||||
const source = parsed as Record<string, unknown>;
|
||||
|
||||
if (source.type !== 'service_account') {
|
||||
return invalid('type 이 service_account 가 아닙니다');
|
||||
}
|
||||
const projectId = textField(source, 'project_id');
|
||||
if (projectId === undefined || !PROJECT_ID_PATTERN.test(projectId)) {
|
||||
return invalid('project_id 가 프로젝트 id 모양이 아닙니다');
|
||||
}
|
||||
const clientEmail = textField(source, 'client_email');
|
||||
if (clientEmail === undefined || !CLIENT_EMAIL_PATTERN.test(clientEmail)) {
|
||||
return invalid(
|
||||
'client_email 이 서비스 계정 주소(…@….iam.gserviceaccount.com)가 아닙니다',
|
||||
);
|
||||
}
|
||||
const privateKeyId = textField(source, 'private_key_id');
|
||||
if (
|
||||
privateKeyId === undefined ||
|
||||
!PRIVATE_KEY_ID_PATTERN.test(privateKeyId)
|
||||
) {
|
||||
return invalid('private_key_id 가 키 id 모양이 아닙니다');
|
||||
}
|
||||
const tokenUri = source.token_uri;
|
||||
if (tokenUri !== undefined && tokenUri !== FCM_OAUTH_TOKEN_URL) {
|
||||
return invalid(`token_uri 가 ${FCM_OAUTH_TOKEN_URL} 이 아닙니다`);
|
||||
}
|
||||
const pem = textField(source, 'private_key');
|
||||
if (pem === undefined) return invalid('private_key 가 없습니다');
|
||||
const privateKey = readPrivateKey(pem);
|
||||
if (typeof privateKey === 'string') return invalid(privateKey);
|
||||
|
||||
return {
|
||||
status: 'valid',
|
||||
credentials: { projectId, clientEmail, privateKeyId, privateKey },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { fcmMessageOf } from './fcm-message';
|
||||
|
||||
const FID = 'dQw4w9WgXcQ-ExampleFid';
|
||||
|
||||
/** JSON 의 모든 키 경로 (배열은 없다) — 메시지에 정해진 칸 밖이 끼지 않는지 본다 */
|
||||
function keyPaths(value: unknown, prefix = ''): string[] {
|
||||
if (typeof value !== 'object' || value === null) return [prefix];
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(
|
||||
([key, child]) => keyPaths(child, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
describe('fcmMessageOf (앱 알림의 FCM 메시지 — 계약 1절 "푸시 모양")', () => {
|
||||
it('새 메일 — 제목 "새 메일 N통"만, data 는 모듈 · 종류, 안드로이드는 같은 tag · 채널 · 높은 우선순위 · 하루 수명 · 개수', () => {
|
||||
expect(fcmMessageOf(FID, { kind: 'new-mail', count: 3 })).toEqual({
|
||||
fid: FID,
|
||||
notification: { title: '새 메일 3통' },
|
||||
data: { module: 'mail', kind: 'new-mail' },
|
||||
android: {
|
||||
collapse_key: 'new-mail',
|
||||
priority: 'HIGH',
|
||||
ttl: '86400s',
|
||||
notification: {
|
||||
tag: 'new-mail',
|
||||
channel_id: 'new_mail',
|
||||
notification_priority: 'PRIORITY_HIGH',
|
||||
visibility: 'PUBLIC',
|
||||
notification_count: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('개수는 천 단위 쉼표 — 1통 · 12통 · 1,234통', () => {
|
||||
const titleOf = (count: number) =>
|
||||
fcmMessageOf(FID, { kind: 'new-mail', count }).notification.title;
|
||||
expect(titleOf(1)).toBe('새 메일 1통');
|
||||
expect(titleOf(12)).toBe('새 메일 12통');
|
||||
expect(titleOf(1234)).toBe('새 메일 1,234통');
|
||||
});
|
||||
|
||||
it('칸은 정해진 것뿐이다 — 받는 기기(fid) 밖에 주소 · 이름 · 제목 · 메일 id · 세션 id 를 담을 칸이 없다', () => {
|
||||
expect(keyPaths(fcmMessageOf(FID, { kind: 'new-mail', count: 2 }))).toEqual(
|
||||
[
|
||||
'fid',
|
||||
'notification.title',
|
||||
'data.module',
|
||||
'data.kind',
|
||||
'android.collapse_key',
|
||||
'android.priority',
|
||||
'android.ttl',
|
||||
'android.notification.tag',
|
||||
'android.notification.channel_id',
|
||||
'android.notification.notification_priority',
|
||||
'android.notification.visibility',
|
||||
'android.notification.notification_count',
|
||||
],
|
||||
);
|
||||
// data 의 값은 FCM 이 글만 받는다
|
||||
const { data } = fcmMessageOf(FID, { kind: 'session-ended' });
|
||||
expect(
|
||||
Object.values(data).every((value) => typeof value === 'string'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('세션 끝 알림(SESSION_ENDED_NOTICE 의 까닭) — 같은 tag · 채널 · collapse_key 로 옛 "새 메일 N통"을 바꿔 쓴다, 제목 · 본문은 확정 글(push-text.ts), 개수 칸 없음', () => {
|
||||
expect(fcmMessageOf(FID, { kind: 'session-ended' })).toEqual({
|
||||
fid: FID,
|
||||
notification: {
|
||||
title: '새 메일 알림이 멈췄습니다',
|
||||
body: '안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요.',
|
||||
},
|
||||
data: { module: 'mail', kind: 'session-ended' },
|
||||
android: {
|
||||
collapse_key: 'new-mail',
|
||||
priority: 'NORMAL',
|
||||
ttl: '86400s',
|
||||
notification: {
|
||||
tag: 'new-mail',
|
||||
channel_id: 'new_mail',
|
||||
notification_priority: 'PRIORITY_DEFAULT',
|
||||
visibility: 'PUBLIC',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { PushNotice } from '@/modules/push/push-sender.port';
|
||||
import { SESSION_ENDED_TEXT, newMailTitle } from '@/modules/push/push-text';
|
||||
import {
|
||||
FCM_CHANNEL_ID,
|
||||
FCM_COLLAPSE_KEY,
|
||||
FCM_NOTIFICATION_TAG,
|
||||
FCM_TTL,
|
||||
PUSH_MODULE_MAIL,
|
||||
} from './fcm.constants';
|
||||
|
||||
/**
|
||||
* FCM HTTP v1 `messages:send` 의 `message` (계약 1절 "푸시 모양") — 순수 함수.
|
||||
* 받는 것은 기기 주소(FID)와 알림(종류 · 개수)뿐이다 — 주소 · 이름 · 제목 · 메일 id · 세션 id 를 받지 않으므로 싣지도 못한다.
|
||||
* 칸 이름은 FCM v1 JSON 의 snake_case 다(android.*). 안드로이드 알림 하나를 같은 tag 로 바꿔 쓴다 — 로그인이 끝난 알림도 같은 자리에
|
||||
*/
|
||||
|
||||
/** 시스템이 그리는 알림 칸 */
|
||||
export interface FcmNotification {
|
||||
readonly title: string;
|
||||
readonly body?: string;
|
||||
}
|
||||
|
||||
/** 안드로이드 알림 칸 (AndroidNotification) */
|
||||
export interface FcmAndroidNotification {
|
||||
readonly tag: string;
|
||||
readonly channel_id: string;
|
||||
readonly notification_priority: 'PRIORITY_HIGH' | 'PRIORITY_DEFAULT';
|
||||
readonly visibility: 'PUBLIC';
|
||||
readonly notification_count?: number;
|
||||
}
|
||||
|
||||
/** 안드로이드 칸 (AndroidConfig) */
|
||||
export interface FcmAndroidConfig {
|
||||
readonly collapse_key: string;
|
||||
readonly priority: 'HIGH' | 'NORMAL';
|
||||
readonly ttl: string;
|
||||
readonly notification: FcmAndroidNotification;
|
||||
}
|
||||
|
||||
/** 보내는 메시지 — 앱이 누른 뒤 갈 곳은 data(모듈 · 종류)로만 안다 */
|
||||
export interface FcmMessage {
|
||||
readonly fid: string;
|
||||
readonly notification: FcmNotification;
|
||||
readonly data: { readonly module: string; readonly kind: PushNotice['kind'] };
|
||||
readonly android: FcmAndroidConfig;
|
||||
}
|
||||
|
||||
/** 알림 하나 → 메시지 (FID 는 받는 기기 — 내용에는 들지 않는다) */
|
||||
export function fcmMessageOf(fid: string, notice: PushNotice): FcmMessage {
|
||||
if (notice.kind === 'new-mail') {
|
||||
return {
|
||||
fid,
|
||||
notification: { title: newMailTitle(notice.count) },
|
||||
data: { module: PUSH_MODULE_MAIL, kind: notice.kind },
|
||||
android: {
|
||||
collapse_key: FCM_COLLAPSE_KEY,
|
||||
priority: 'HIGH',
|
||||
ttl: FCM_TTL,
|
||||
notification: {
|
||||
tag: FCM_NOTIFICATION_TAG,
|
||||
channel_id: FCM_CHANNEL_ID,
|
||||
notification_priority: 'PRIORITY_HIGH',
|
||||
visibility: 'PUBLIC',
|
||||
// 런처 배지의 수 — 기종마다 보이는지 다르다(확인 필요)
|
||||
notification_count: notice.count,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// 그 휴대폰을 건드리지 않았는데 앱 세션이 끝났다(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) — 같은 tag · 채널로 옛 "새 메일 N통"을 바꿔 쓴다. 급하지 않아 보통 우선순위
|
||||
return {
|
||||
fid,
|
||||
notification: {
|
||||
title: SESSION_ENDED_TEXT.title,
|
||||
body: SESSION_ENDED_TEXT.body,
|
||||
},
|
||||
data: { module: PUSH_MODULE_MAIL, kind: notice.kind },
|
||||
android: {
|
||||
collapse_key: FCM_COLLAPSE_KEY,
|
||||
priority: 'NORMAL',
|
||||
ttl: FCM_TTL,
|
||||
notification: {
|
||||
tag: FCM_NOTIFICATION_TAG,
|
||||
channel_id: FCM_CHANNEL_ID,
|
||||
notification_priority: 'PRIORITY_DEFAULT',
|
||||
visibility: 'PUBLIC',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { testServiceAccount } from '@/../test/fcm-test-key';
|
||||
import {
|
||||
type FcmCredentials,
|
||||
readFcmServiceAccountKey,
|
||||
} from './fcm-credentials';
|
||||
import { fcmMessageOf } from './fcm-message';
|
||||
import {
|
||||
FcmPushSender,
|
||||
classifyFcmResponse,
|
||||
readFcmError,
|
||||
retryAfterMs,
|
||||
} from './fcm-sender';
|
||||
import type { FcmHttpResponse, FcmTransport } from './fcm-transport';
|
||||
import { FCM_REQUEST_TIMEOUT_MS, FCM_SEND_DEADLINE_MS } from './fcm.constants';
|
||||
|
||||
const account = testServiceAccount();
|
||||
const credentials = ((): FcmCredentials => {
|
||||
const reading = readFcmServiceAccountKey(account.base64);
|
||||
if (reading.status !== 'valid') throw new Error('시험 키를 읽지 못했다');
|
||||
return reading.credentials;
|
||||
})();
|
||||
|
||||
const FID = 'dQw4w9WgXcQ-SecretFid01';
|
||||
const NOW = Date.parse('2026-11-07T05:00:00Z');
|
||||
|
||||
/** FCM 의 오류 본문 (v1 — google.rpc.Status + FcmError · BadRequest 상세) */
|
||||
function fcmError(
|
||||
code: number,
|
||||
status: string,
|
||||
errorCode: string | null,
|
||||
fields: string[] = [],
|
||||
): FcmHttpResponse {
|
||||
const details: Record<string, unknown>[] = [];
|
||||
if (errorCode) {
|
||||
details.push({
|
||||
'@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError',
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
details.push({
|
||||
'@type': 'type.googleapis.com/google.rpc.BadRequest',
|
||||
fieldViolations: fields.map((field) => ({
|
||||
field,
|
||||
description: `Invalid value at '${field}' (${FID})`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: code,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code,
|
||||
message: `Requested entity was not found. ${FID}`,
|
||||
status,
|
||||
details,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const ok: FcmHttpResponse = {
|
||||
status: 200,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({ name: 'projects/arasystem-push-test/messages/1' }),
|
||||
};
|
||||
const transient = (status: number, retryAfter: string | null = null) => ({
|
||||
status,
|
||||
retryAfter,
|
||||
body: '',
|
||||
});
|
||||
|
||||
type Answer = FcmHttpResponse | Error | 'hang';
|
||||
|
||||
/** FCM 대역 — 토큰 교환 · 보내기에 준비한 답을 차례로 준다 */
|
||||
function fakeFcm(sends: Answer[], tokens: Answer[] = []) {
|
||||
const sendQueue = [...sends];
|
||||
const tokenQueue = [...tokens];
|
||||
const sent: { projectId: string; token: string; body: string }[] = [];
|
||||
let tokenRequests = 0;
|
||||
let tokenSequence = 0;
|
||||
const answer = (next: Answer | undefined, signal: AbortSignal) => {
|
||||
if (next === undefined)
|
||||
return Promise.reject(new Error('준비한 답이 없다'));
|
||||
if (next === 'hang') {
|
||||
return new Promise<FcmHttpResponse>((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
|
||||
};
|
||||
const transport: FcmTransport = {
|
||||
requestToken: (_form, signal) => {
|
||||
tokenRequests += 1;
|
||||
const next = tokenQueue.shift();
|
||||
if (next !== undefined) return answer(next, signal);
|
||||
tokenSequence += 1;
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({
|
||||
access_token: `ya29.token-${tokenSequence}`,
|
||||
expires_in: 3599,
|
||||
}),
|
||||
});
|
||||
},
|
||||
sendMessage: (projectId, token, body, signal) => {
|
||||
sent.push({ projectId, token, body });
|
||||
return answer(sendQueue.shift(), signal);
|
||||
},
|
||||
};
|
||||
return {
|
||||
transport,
|
||||
sent,
|
||||
get tokenRequests() {
|
||||
return tokenRequests;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
describe('FCM 응답 읽기 (계약 "실패" 표)', () => {
|
||||
it('오류 본문에서 상태 · FCM 오류 코드 · 틀린 칸만 읽는다 (설명 글은 읽지 않는다)', () => {
|
||||
expect(
|
||||
readFcmError(
|
||||
fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid'])
|
||||
.body,
|
||||
),
|
||||
).toEqual({
|
||||
status: 'INVALID_ARGUMENT',
|
||||
errorCode: 'INVALID_ARGUMENT',
|
||||
fields: ['message.fid'],
|
||||
});
|
||||
expect(readFcmError('<html>bad gateway</html>')).toEqual({
|
||||
status: null,
|
||||
errorCode: null,
|
||||
fields: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('Retry-After 는 초 · HTTP-date 를 읽는다', () => {
|
||||
expect(retryAfterMs('7', NOW)).toBe(7_000);
|
||||
expect(retryAfterMs(new Date(NOW + 3_000).toUTCString(), NOW)).toBe(3_000);
|
||||
expect(retryAfterMs('soon', NOW)).toBeNull();
|
||||
expect(retryAfterMs(null, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('답마다의 갈래 — 지울 기기 · 토큰 다시 · 설정 오류 · 다시 하기', () => {
|
||||
const kind = (response: FcmHttpResponse) =>
|
||||
classifyFcmResponse(response, NOW).kind;
|
||||
expect(kind(ok)).toBe('sent');
|
||||
expect(kind(fcmError(404, 'NOT_FOUND', 'UNREGISTERED'))).toBe('remove');
|
||||
expect(kind(fcmError(403, 'PERMISSION_DENIED', 'SENDER_ID_MISMATCH'))).toBe(
|
||||
'remove',
|
||||
);
|
||||
expect(
|
||||
kind(
|
||||
fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid']),
|
||||
),
|
||||
).toBe('remove');
|
||||
expect(kind(fcmError(401, 'UNAUTHENTICATED', null))).toBe('renew-token');
|
||||
for (const response of [
|
||||
fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', [
|
||||
'message.android.ttl',
|
||||
]),
|
||||
fcmError(403, 'PERMISSION_DENIED', null),
|
||||
// 프로젝트 id 가 틀린 404 — 오류 코드가 없다(모든 등록을 지우지 않게)
|
||||
fcmError(404, 'NOT_FOUND', null),
|
||||
fcmError(401, 'UNAUTHENTICATED', 'THIRD_PARTY_AUTH_ERROR'),
|
||||
{ status: 302, retryAfter: null, body: '' },
|
||||
]) {
|
||||
expect(kind(response)).toBe('config');
|
||||
}
|
||||
for (const status of [500, 502, 503]) {
|
||||
expect(kind(transient(status))).toBe('retry');
|
||||
}
|
||||
// 429 는 따로 — Retry-After 가 있을 때만 그때 한 번 다시
|
||||
expect(kind(transient(429))).toBe('quota');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FcmPushSender (기기 하나에 보내기 — 다시 하기 · 시간 한도 · 로그)', () => {
|
||||
let logs: { level: string; text: string }[];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({
|
||||
now: NOW,
|
||||
doNotFake: ['nextTick', 'queueMicrotask', 'setImmediate'],
|
||||
});
|
||||
logs = [];
|
||||
for (const level of ['error', 'warn', 'log', 'debug'] as const) {
|
||||
jest
|
||||
.spyOn(Logger.prototype, level)
|
||||
.mockImplementation((...args: unknown[]) => {
|
||||
logs.push({ level, text: String(args[0]) });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** 가짜 시계를 넘기고 이어진 일(약속)을 마저 돌린다 */
|
||||
const advance = async (ms: number) => {
|
||||
await jest.advanceTimersByTimeAsync(ms);
|
||||
await flush();
|
||||
};
|
||||
|
||||
/** 난수 0.5 — 흔들림 없이 1 · 4초 */
|
||||
const senderOf = (fake: ReturnType<typeof fakeFcm>) =>
|
||||
new FcmPushSender(credentials, fake.transport, { random: () => 0.5 });
|
||||
|
||||
it('200 이면 보냄 — 본문은 { message: fcmMessageOf } · 주소는 프로젝트 · Bearer 는 교환한 토큰', async () => {
|
||||
const fake = fakeFcm([ok]);
|
||||
const outcome = await senderOf(fake).send(FID, {
|
||||
kind: 'new-mail',
|
||||
count: 2,
|
||||
});
|
||||
|
||||
expect(outcome).toBe('sent');
|
||||
expect(fake.sent).toEqual([
|
||||
{
|
||||
projectId: 'arasystem-push-test',
|
||||
token: 'ya29.token-1',
|
||||
body: JSON.stringify({
|
||||
message: fcmMessageOf(FID, { kind: 'new-mail', count: 2 }),
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('같은 보내는 쪽은 토큰을 기억한다 — 두 번 보내도 교환은 한 번', async () => {
|
||||
const fake = fakeFcm([ok, ok]);
|
||||
const sender = senderOf(fake);
|
||||
await sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
await sender.send(FID, { kind: 'new-mail', count: 2 });
|
||||
expect(fake.tokenRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('그 기기가 더는 받지 않으면 unregistered — 다시 하지 않는다 (UNREGISTERED · SENDER_ID_MISMATCH · message.fid 의 INVALID_ARGUMENT)', async () => {
|
||||
for (const response of [
|
||||
fcmError(404, 'NOT_FOUND', 'UNREGISTERED'),
|
||||
fcmError(403, 'PERMISSION_DENIED', 'SENDER_ID_MISMATCH'),
|
||||
fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', ['message.fid']),
|
||||
]) {
|
||||
const fake = fakeFcm([response]);
|
||||
await expect(
|
||||
senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('unregistered');
|
||||
expect(fake.sent).toHaveLength(1);
|
||||
}
|
||||
expect(logs.filter((log) => log.level === 'debug')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('401 이면 토큰을 버리고 새 토큰으로 한 번 더 — 또 401 이면 설정 오류(error)', async () => {
|
||||
const renewed = fakeFcm([fcmError(401, 'UNAUTHENTICATED', null), ok]);
|
||||
await expect(
|
||||
senderOf(renewed).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('sent');
|
||||
expect(renewed.sent.map(({ token }) => token)).toEqual([
|
||||
'ya29.token-1',
|
||||
'ya29.token-2',
|
||||
]);
|
||||
|
||||
const twice = fakeFcm([
|
||||
fcmError(401, 'UNAUTHENTICATED', null),
|
||||
fcmError(401, 'UNAUTHENTICATED', null),
|
||||
]);
|
||||
await expect(
|
||||
senderOf(twice).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(
|
||||
logs.filter((log) => log.level === 'error').map((log) => log.text),
|
||||
).toEqual([
|
||||
expect.stringContaining('reason=http-401') as unknown as string,
|
||||
]);
|
||||
});
|
||||
|
||||
it('잠깐의 장애(503)는 1초 뒤 다시 — 그 전에는 보내지 않는다', async () => {
|
||||
const fake = fakeFcm([transient(503), ok]);
|
||||
const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 });
|
||||
await flush();
|
||||
expect(fake.sent).toHaveLength(1);
|
||||
|
||||
await advance(999);
|
||||
expect(fake.sent).toHaveLength(1);
|
||||
await advance(1);
|
||||
await expect(pending).resolves.toBe('sent');
|
||||
expect(fake.sent).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('시도 3번(1 · 4초 쉼)으로도 안 되면 failed — warn 한 번(상태 · 시도 수만)', async () => {
|
||||
const fake = fakeFcm([transient(500), transient(500), transient(500)]);
|
||||
const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 });
|
||||
await advance(1_000);
|
||||
expect(fake.sent).toHaveLength(2);
|
||||
await advance(4_000);
|
||||
await expect(pending).resolves.toBe('failed');
|
||||
expect(fake.sent).toHaveLength(3);
|
||||
expect(
|
||||
logs.filter((log) => log.level === 'warn').map((log) => log.text),
|
||||
).toEqual([
|
||||
expect.stringContaining(
|
||||
'reason=http-500, attempts=3',
|
||||
) as unknown as string,
|
||||
]);
|
||||
});
|
||||
|
||||
it('429 는 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 없거나 넘으면 이번 알림은 버린다 (리뷰 L5)', async () => {
|
||||
const fake = fakeFcm([transient(429, '2'), ok]);
|
||||
const pending = senderOf(fake).send(FID, { kind: 'new-mail', count: 1 });
|
||||
await advance(1_000);
|
||||
expect(fake.sent).toHaveLength(1);
|
||||
await advance(1_000);
|
||||
await expect(pending).resolves.toBe('sent');
|
||||
|
||||
const noRetryAfter = fakeFcm([transient(429), ok]);
|
||||
await expect(
|
||||
senderOf(noRetryAfter).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(noRetryAfter.sent).toHaveLength(1);
|
||||
expect(logs.filter((log) => log.level === 'warn').at(-1)?.text).toContain(
|
||||
'reason=http-429',
|
||||
);
|
||||
|
||||
// 다시 보낸 것이 또 429 면 그만둔다(한 번만)
|
||||
const twice = fakeFcm([transient(429, '1'), transient(429, '1'), ok]);
|
||||
const again = senderOf(twice).send(FID, { kind: 'new-mail', count: 1 });
|
||||
await advance(1_000);
|
||||
await expect(again).resolves.toBe('failed');
|
||||
expect(twice.sent).toHaveLength(2);
|
||||
|
||||
const tooLong = fakeFcm([transient(429, '60'), ok]);
|
||||
await expect(
|
||||
senderOf(tooLong).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(tooLong.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('5xx 의 Retry-After 도 남은 시간을 넘으면 기다리지 않고 그만둔다', async () => {
|
||||
const tooLong = fakeFcm([transient(503, '60'), ok]);
|
||||
await expect(
|
||||
senderOf(tooLong).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(tooLong.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('보내기 직전(다시 하기마다) 부른 쪽의 확인을 본다 — false 면 보내지 않고 skipped, 던지면 보내지 않고 failed (리뷰 L1)', async () => {
|
||||
const never = fakeFcm([ok]);
|
||||
await expect(
|
||||
senderOf(never).send(
|
||||
FID,
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ stillValid: () => Promise.resolve(false) },
|
||||
),
|
||||
).resolves.toBe('skipped');
|
||||
expect(never.sent).toHaveLength(0);
|
||||
|
||||
// 첫 시도 뒤 503 — 쉬는 사이 그 휴대폰이 옮겨 갔다
|
||||
const checks = [true, false];
|
||||
const moved = fakeFcm([transient(503), ok]);
|
||||
const pending = senderOf(moved).send(
|
||||
FID,
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ stillValid: () => Promise.resolve(checks.shift() ?? false) },
|
||||
);
|
||||
await advance(1_000);
|
||||
await expect(pending).resolves.toBe('skipped');
|
||||
expect(moved.sent).toHaveLength(1);
|
||||
|
||||
const broken = fakeFcm([ok]);
|
||||
await expect(
|
||||
senderOf(broken).send(
|
||||
FID,
|
||||
{ kind: 'new-mail', count: 1 },
|
||||
{ stillValid: () => Promise.reject(new Error('db down')) },
|
||||
),
|
||||
).resolves.toBe('failed');
|
||||
expect(broken.sent).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('연결 오류 뒤 다시 — 응답이 오지 않으면 시도마다 10초에서 끊고, 한 번 보내기는 20초 안', async () => {
|
||||
const network = fakeFcm([new Error('ECONNRESET'), ok]);
|
||||
const first = senderOf(network).send(FID, { kind: 'new-mail', count: 1 });
|
||||
await advance(1_000);
|
||||
await expect(first).resolves.toBe('sent');
|
||||
|
||||
// 0초 시도 1 → 10초에 끊김 → 1초 쉼 → 11초 시도 2(남은 9초) → 20초에 끊김 → 4초를 더 쉴 시간이 없다 → 그만둔다
|
||||
const hanging = fakeFcm(['hang', 'hang', 'hang']);
|
||||
let outcome: string | null = null;
|
||||
void senderOf(hanging)
|
||||
.send(FID, { kind: 'new-mail', count: 1 })
|
||||
.then((result) => {
|
||||
outcome = result;
|
||||
});
|
||||
await advance(FCM_REQUEST_TIMEOUT_MS);
|
||||
await flush();
|
||||
expect(hanging.sent).toHaveLength(1);
|
||||
await advance(FCM_SEND_DEADLINE_MS - FCM_REQUEST_TIMEOUT_MS - 1);
|
||||
await flush();
|
||||
expect(hanging.sent).toHaveLength(2);
|
||||
expect(outcome).toBeNull();
|
||||
await advance(1);
|
||||
await flush();
|
||||
expect(outcome).toBe('failed');
|
||||
});
|
||||
|
||||
it('우리 설정 · 메시지가 틀린 답은 다시 하지 않고 error — 같은 종류는 한 시간에 한 번(묻힌 수를 다음 줄에)', async () => {
|
||||
const fake = fakeFcm([
|
||||
fcmError(403, 'PERMISSION_DENIED', null),
|
||||
fcmError(403, 'PERMISSION_DENIED', null),
|
||||
fcmError(404, 'NOT_FOUND', null),
|
||||
fcmError(403, 'PERMISSION_DENIED', null),
|
||||
]);
|
||||
const sender = senderOf(fake);
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await expect(
|
||||
sender.send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
}
|
||||
expect(fake.sent).toHaveLength(3);
|
||||
expect(logs.filter((log) => log.level === 'error')).toHaveLength(2);
|
||||
|
||||
await advance(60 * 60_000);
|
||||
await sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
const errors = logs.filter((log) => log.level === 'error');
|
||||
expect(errors).toHaveLength(3);
|
||||
expect(errors[2].text).toContain('같은 실패 1건 더');
|
||||
});
|
||||
|
||||
it('OAuth 가 거절하면 보내지 않는다 — error(OAuth 의 error 코드만)', async () => {
|
||||
const fake = fakeFcm(
|
||||
[ok],
|
||||
[
|
||||
{
|
||||
status: 400,
|
||||
retryAfter: null,
|
||||
body: JSON.stringify({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid JWT',
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
await expect(
|
||||
senderOf(fake).send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(fake.sent).toHaveLength(0);
|
||||
expect(logs.find((log) => log.level === 'error')?.text).toContain(
|
||||
'reason=oauth-http-400/invalid_grant',
|
||||
);
|
||||
});
|
||||
|
||||
it('서버 종료 — 쉬는 중 · 보내는 중이면 바로 그만두고(로그 없이 — 배포마다 잡음이 되지 않게), 그 뒤로는 보내지 않는다 (리뷰 L4)', async () => {
|
||||
const fake = fakeFcm([transient(503), ok, ok]);
|
||||
const sender = senderOf(fake);
|
||||
const pending = sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
await flush();
|
||||
sender.shutdown();
|
||||
await expect(pending).resolves.toBe('failed');
|
||||
await expect(
|
||||
sender.send(FID, { kind: 'new-mail', count: 1 }),
|
||||
).resolves.toBe('failed');
|
||||
expect(fake.sent).toHaveLength(1);
|
||||
|
||||
const inFlight = fakeFcm(['hang']);
|
||||
const sending = senderOf(inFlight);
|
||||
const hanging = sending.send(FID, { kind: 'new-mail', count: 1 });
|
||||
await flush();
|
||||
sending.shutdown();
|
||||
await expect(hanging).resolves.toBe('failed');
|
||||
expect(logs.filter((log) => log.level === 'warn')).toEqual([]);
|
||||
});
|
||||
|
||||
it('로그에 FID · access token · 개인 키 · FCM 의 설명 글이 없다', async () => {
|
||||
const fake = fakeFcm([
|
||||
fcmError(404, 'NOT_FOUND', 'UNREGISTERED'),
|
||||
fcmError(400, 'INVALID_ARGUMENT', 'INVALID_ARGUMENT', [
|
||||
'message.android',
|
||||
]),
|
||||
transient(503),
|
||||
transient(503),
|
||||
transient(503),
|
||||
]);
|
||||
const sender = senderOf(fake);
|
||||
await sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
await sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
const third = sender.send(FID, { kind: 'new-mail', count: 1 });
|
||||
await advance(5_000);
|
||||
await third;
|
||||
|
||||
const text = logs.map((log) => log.text).join('\n');
|
||||
expect(logs.length).toBeGreaterThanOrEqual(3);
|
||||
expect(text).not.toContain(FID);
|
||||
expect(text).not.toContain('ya29');
|
||||
expect(text).not.toContain('BEGIN');
|
||||
expect(text).not.toContain('Requested entity');
|
||||
expect(text).not.toContain('Invalid value');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
PushNotice,
|
||||
PushSendOptions,
|
||||
PushSendOutcome,
|
||||
} from '@/modules/push/push-sender.port';
|
||||
import { ConcurrencyGate } from '@/shared/utils/concurrency-gate';
|
||||
import { FcmAccessTokens, FcmAuthError } from './fcm-auth';
|
||||
import type { FcmCredentials } from './fcm-credentials';
|
||||
import { fcmMessageOf } from './fcm-message';
|
||||
import { deadlineSignal } from './fcm-timeout';
|
||||
import type { FcmHttpResponse, FcmTransport } from './fcm-transport';
|
||||
import {
|
||||
FCM_FAILURE_LOG_INTERVAL_MS,
|
||||
FCM_REQUEST_TIMEOUT_MS,
|
||||
FCM_RETRY_DELAYS_MS,
|
||||
FCM_RETRY_JITTER,
|
||||
FCM_SEND_ATTEMPTS,
|
||||
FCM_SEND_CONCURRENCY,
|
||||
FCM_SEND_DEADLINE_MS,
|
||||
} from './fcm.constants';
|
||||
|
||||
/**
|
||||
* FCM HTTP v1 으로 기기 하나에 보낸다 (계약 1절 "앱 새 메일 알림 · 앱 설정"의 "실패") — 새 의존성 없이(fcm-auth.ts 의 RS256 + 전역 fetch).
|
||||
*
|
||||
* | 답 | 할 일 |
|
||||
* | --- | --- |
|
||||
* | 2xx | 보냄 |
|
||||
* | 404 UNREGISTERED · 403 SENDER_ID_MISMATCH · 400 INVALID_ARGUMENT 의 칸 `message.fid` | 그 기기는 더 받지 않는다 → unregistered (부른 쪽이 FID 가 그대로일 때만 등록을 지운다) |
|
||||
* | 401(THIRD_PARTY_AUTH_ERROR 밖) | access token 을 버리고 새 토큰으로 한 번 더 |
|
||||
* | 429 (QUOTA_EXCEEDED) | 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다(다음 새 메일이 그때의 N 으로 다시 간다) |
|
||||
* | 5xx · 연결 오류 · 시간 초과 · OAuth 의 잠깐 장애 | 다시 — 1 · 4초(±20%) 쉬고, Retry-After 가 있으면 그것을(남은 시간 안에서만). 시도 3번 · 한 번 보내기 20초 안 |
|
||||
* | 그 밖(400 · 403 PERMISSION_DENIED · 오류 코드 없는 404 · 3xx · OAuth 거절) | 우리 설정 · 메시지가 틀렸다 — 등록을 지우지 않는다(프로젝트 id 가 틀린 404 로 모든 등록을 지우지 않게) |
|
||||
*
|
||||
* 서버 전체에서 함께 보내는 수는 FCM_SEND_CONCURRENCY(시도 한 번이 자리 하나 — 쉬는 동안은 자리를 잡지 않는다).
|
||||
* 로그: FID · 키 · JWT · access token · FCM 오류의 설명 글은 남기지 않는다 — 상태 · 오류 코드만. 설정 오류는 error, 끝내 못 보냄은 warn —
|
||||
* 종류마다 FCM_FAILURE_LOG_INTERVAL_MS 에 한 번(그사이 묻힌 수를 함께). 기기가 받지 않음은 debug(반복되는 정상 흐름).
|
||||
* 서버 종료(shutdown) 때 진행 중인 요청 · 쉬기를 끊는다 — 그때 끊긴 보내기는 로그 없이 failed 다(배포마다 남는 잡음이 되지 않게).
|
||||
* 보내기 직전(다시 하기마다) 부른 쪽의 `stillValid` 를 본다 — false 면 보내지 않고 skipped (그 휴대폰이 다른 세션으로 옮겨 갔다 등)
|
||||
*/
|
||||
|
||||
/** FCM 오류 본문에서 읽는 것 — 상태 이름 · FCM 오류 코드 · 틀린 칸 (설명 글은 읽지 않는다) */
|
||||
export interface FcmErrorShape {
|
||||
readonly status: string | null;
|
||||
readonly errorCode: string | null;
|
||||
readonly fields: readonly string[];
|
||||
}
|
||||
|
||||
/** 시도 한 번의 갈래 */
|
||||
export type FcmAttemptResult =
|
||||
| { readonly kind: 'sent' }
|
||||
| { readonly kind: 'remove'; readonly reason: string }
|
||||
| { readonly kind: 'renew-token' }
|
||||
| { readonly kind: 'config'; readonly reason: string }
|
||||
| {
|
||||
/** 429 — Retry-After 가 있을 때만 그때 한 번 다시 */
|
||||
readonly kind: 'quota';
|
||||
readonly reason: string;
|
||||
readonly retryAfterMs: number | null;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'retry';
|
||||
readonly reason: string;
|
||||
readonly retryAfterMs: number | null;
|
||||
};
|
||||
|
||||
/** 상태 · 오류 코드 이름의 모양 — 그 밖의 글은 버린다 */
|
||||
const ERROR_NAME = /^[A-Z][A-Z_]{0,63}$/;
|
||||
/** 틀린 칸 이름의 모양 (`message.fid` 등) */
|
||||
const FIELD_NAME = /^[A-Za-z0-9_.[\]]{1,128}$/;
|
||||
/** Retry-After 의 초 (HTTP-date 는 Date.parse 로) */
|
||||
const RETRY_AFTER_SECONDS = /^\d{1,6}$/;
|
||||
/** 그 기기를 지우는 400 의 칸 — 대상 FID 가 틀림 */
|
||||
const TARGET_FIELD = 'message.fid';
|
||||
|
||||
export function readFcmError(body: string): FcmErrorShape {
|
||||
const empty: FcmErrorShape = { status: null, errorCode: null, fields: [] };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
const error =
|
||||
typeof parsed === 'object' && parsed !== null
|
||||
? (parsed as { error?: unknown }).error
|
||||
: undefined;
|
||||
if (typeof error !== 'object' || error === null) return empty;
|
||||
const { status, details } = error as { status?: unknown; details?: unknown };
|
||||
let errorCode: string | null = null;
|
||||
const fields: string[] = [];
|
||||
if (Array.isArray(details)) {
|
||||
for (const detail of details as unknown[]) {
|
||||
if (typeof detail !== 'object' || detail === null) continue;
|
||||
const { errorCode: code, fieldViolations } = detail as {
|
||||
errorCode?: unknown;
|
||||
fieldViolations?: unknown;
|
||||
};
|
||||
if (
|
||||
errorCode === null &&
|
||||
typeof code === 'string' &&
|
||||
ERROR_NAME.test(code)
|
||||
) {
|
||||
errorCode = code;
|
||||
}
|
||||
if (!Array.isArray(fieldViolations)) continue;
|
||||
for (const violation of fieldViolations as unknown[]) {
|
||||
const field =
|
||||
typeof violation === 'object' && violation !== null
|
||||
? (violation as { field?: unknown }).field
|
||||
: undefined;
|
||||
if (typeof field === 'string' && FIELD_NAME.test(field)) {
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status:
|
||||
typeof status === 'string' && ERROR_NAME.test(status) ? status : null,
|
||||
errorCode,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** Retry-After → 기다릴 ms (초 · HTTP-date). 읽지 못하면 null */
|
||||
export function retryAfterMs(value: string | null, now: number): number | null {
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim();
|
||||
if (RETRY_AFTER_SECONDS.test(trimmed)) return Number(trimmed) * 1000;
|
||||
const at = Date.parse(trimmed);
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - now);
|
||||
}
|
||||
|
||||
/** 응답 하나의 갈래 (계약의 "실패" 표) */
|
||||
export function classifyFcmResponse(
|
||||
response: FcmHttpResponse,
|
||||
now: number,
|
||||
): FcmAttemptResult {
|
||||
const { status } = response;
|
||||
if (status >= 200 && status < 300) return { kind: 'sent' };
|
||||
if (status === 429) {
|
||||
return {
|
||||
kind: 'quota',
|
||||
reason: 'http-429',
|
||||
retryAfterMs: retryAfterMs(response.retryAfter, now),
|
||||
};
|
||||
}
|
||||
if (status >= 500) {
|
||||
return {
|
||||
kind: 'retry',
|
||||
reason: `http-${status}`,
|
||||
retryAfterMs: retryAfterMs(response.retryAfter, now),
|
||||
};
|
||||
}
|
||||
const error = readFcmError(response.body);
|
||||
const name = error.errorCode ?? error.status ?? '-';
|
||||
if (status === 404 && error.errorCode === 'UNREGISTERED') {
|
||||
return { kind: 'remove', reason: 'UNREGISTERED' };
|
||||
}
|
||||
if (status === 403 && error.errorCode === 'SENDER_ID_MISMATCH') {
|
||||
return { kind: 'remove', reason: 'SENDER_ID_MISMATCH' };
|
||||
}
|
||||
if (
|
||||
status === 400 &&
|
||||
(error.errorCode === 'INVALID_ARGUMENT' ||
|
||||
error.status === 'INVALID_ARGUMENT') &&
|
||||
error.fields.includes(TARGET_FIELD)
|
||||
) {
|
||||
return { kind: 'remove', reason: `INVALID_ARGUMENT/${TARGET_FIELD}` };
|
||||
}
|
||||
if (status === 401 && error.errorCode !== 'THIRD_PARTY_AUTH_ERROR') {
|
||||
return { kind: 'renew-token' };
|
||||
}
|
||||
return { kind: 'config', reason: `http-${status}/${name}` };
|
||||
}
|
||||
|
||||
/** 쉬기 — 서버 종료 신호가 오면 바로 끝난다 (타이머는 프로세스를 붙잡지 않는다) */
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
timer.unref();
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** 종류마다 정한 간격에 한 번만 남기는 로그 — 묻힌 수를 다음 줄에 함께 적는다 */
|
||||
class ThrottledLog {
|
||||
private readonly entries = new Map<
|
||||
string,
|
||||
{ at: number; suppressed: number }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly intervalMs: number,
|
||||
private readonly now: () => number,
|
||||
) {}
|
||||
|
||||
/** 남길 차례면 그사이 묻힌 수(0 이상), 아니면 null */
|
||||
take(kind: string): number | null {
|
||||
const now = this.now();
|
||||
const entry = this.entries.get(kind);
|
||||
if (entry && now - entry.at < this.intervalMs) {
|
||||
entry.suppressed += 1;
|
||||
return null;
|
||||
}
|
||||
this.entries.set(kind, { at: now, suppressed: 0 });
|
||||
return entry?.suppressed ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FcmSenderOptions {
|
||||
/** [0, 1) 난수 — 쉬는 시간의 흔들림 (시험이 정해 넣는다) */
|
||||
readonly random?: () => number;
|
||||
}
|
||||
|
||||
export class FcmPushSender {
|
||||
private readonly logger = new Logger('FcmPushSender');
|
||||
private readonly stop = new AbortController();
|
||||
private readonly tokens: FcmAccessTokens;
|
||||
private readonly gate = new ConcurrencyGate(FCM_SEND_CONCURRENCY);
|
||||
private readonly failureLog = new ThrottledLog(
|
||||
FCM_FAILURE_LOG_INTERVAL_MS,
|
||||
() => Date.now(),
|
||||
);
|
||||
private readonly random: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly credentials: FcmCredentials,
|
||||
private readonly transport: FcmTransport,
|
||||
options: FcmSenderOptions = {},
|
||||
) {
|
||||
this.random = options.random ?? Math.random;
|
||||
this.tokens = new FcmAccessTokens(credentials, transport, this.stop.signal);
|
||||
}
|
||||
|
||||
/** 기기 하나(FID)에 알림 하나 — 던지지 않는다 */
|
||||
async send(
|
||||
fid: string,
|
||||
notice: PushNotice,
|
||||
options: PushSendOptions = {},
|
||||
): Promise<PushSendOutcome> {
|
||||
if (this.stop.signal.aborted) return 'failed';
|
||||
const body = JSON.stringify({ message: fcmMessageOf(fid, notice) });
|
||||
const deadline = Date.now() + FCM_SEND_DEADLINE_MS;
|
||||
let attempts = 0;
|
||||
let renewedToken = false;
|
||||
let quotaRetried = false;
|
||||
|
||||
for (;;) {
|
||||
// 보내기 직전(다시 하기마다) — 그 기기가 아직 이 알림을 받을 자리인가 (읽지 못하면 보내지 않는다 — 닫힌 쪽)
|
||||
if (options.stillValid) {
|
||||
let valid: boolean;
|
||||
try {
|
||||
valid = await options.stillValid();
|
||||
} catch {
|
||||
return this.giveUp('still-valid-check', attempts);
|
||||
}
|
||||
if (!valid) return 'skipped';
|
||||
if (this.stop.signal.aborted) return 'failed';
|
||||
}
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
token = await this.tokens.get();
|
||||
} catch (error) {
|
||||
if (error instanceof FcmAuthError && error.kind === 'rejected') {
|
||||
this.reportConfig(`oauth-${error.reason}`);
|
||||
return 'failed';
|
||||
}
|
||||
// 토큰을 받지 못한 잠깐의 장애 — 보내기 시도 한 번으로 센다
|
||||
attempts += 1;
|
||||
const reason =
|
||||
error instanceof FcmAuthError ? `oauth-${error.reason}` : 'oauth';
|
||||
if (!(await this.pauseBeforeRetry(attempts, null, deadline))) {
|
||||
return this.giveUp(reason, attempts);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
const result = await this.gate.run(() =>
|
||||
this.attempt(token, body, deadline),
|
||||
);
|
||||
switch (result.kind) {
|
||||
case 'sent':
|
||||
return 'sent';
|
||||
case 'remove':
|
||||
this.logger.debug(
|
||||
`앱 알림 — 그 기기는 더 받지 않습니다(등록을 지웁니다): reason=${result.reason}`,
|
||||
);
|
||||
return 'unregistered';
|
||||
case 'renew-token':
|
||||
this.tokens.invalidate(token);
|
||||
if (!renewedToken) {
|
||||
// 토큰이 끝났다 — 새 토큰으로 한 번 더 (시도 수에 넣지 않는다)
|
||||
renewedToken = true;
|
||||
attempts -= 1;
|
||||
continue;
|
||||
}
|
||||
this.reportConfig('http-401');
|
||||
return 'failed';
|
||||
case 'config':
|
||||
this.reportConfig(result.reason);
|
||||
return 'failed';
|
||||
case 'quota':
|
||||
// 바로 다시 보내지 않는다 — Retry-After 가 있고 남은 시간 안이면 그때 한 번, 아니면 이번 알림은 버린다
|
||||
if (quotaRetried || result.retryAfterMs === null) {
|
||||
return this.giveUp(result.reason, attempts);
|
||||
}
|
||||
quotaRetried = true;
|
||||
if (
|
||||
!(await this.pauseBeforeRetry(
|
||||
attempts,
|
||||
result.retryAfterMs,
|
||||
deadline,
|
||||
))
|
||||
) {
|
||||
return this.giveUp(result.reason, attempts);
|
||||
}
|
||||
break;
|
||||
case 'retry':
|
||||
if (
|
||||
!(await this.pauseBeforeRetry(
|
||||
attempts,
|
||||
result.retryAfterMs,
|
||||
deadline,
|
||||
))
|
||||
) {
|
||||
return this.giveUp(result.reason, attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 서버 종료 — 진행 중인 요청 · 쉬기를 끊고 더 보내지 않는다 */
|
||||
shutdown(): void {
|
||||
this.stop.abort();
|
||||
}
|
||||
|
||||
/** 시도 한 번 — 남은 시간과 FCM_REQUEST_TIMEOUT_MS 가운데 짧은 것 안에서 */
|
||||
private async attempt(
|
||||
token: string,
|
||||
body: string,
|
||||
deadline: number,
|
||||
): Promise<FcmAttemptResult> {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
return { kind: 'retry', reason: 'deadline', retryAfterMs: null };
|
||||
}
|
||||
const timer = deadlineSignal(
|
||||
Math.min(FCM_REQUEST_TIMEOUT_MS, remaining),
|
||||
this.stop.signal,
|
||||
);
|
||||
try {
|
||||
const response = await this.transport.sendMessage(
|
||||
this.credentials.projectId,
|
||||
token,
|
||||
body,
|
||||
timer.signal,
|
||||
);
|
||||
return classifyFcmResponse(response, Date.now());
|
||||
} catch {
|
||||
const reason = timer.timedOut()
|
||||
? 'timeout'
|
||||
: this.stop.signal.aborted
|
||||
? 'shutdown'
|
||||
: 'network';
|
||||
return { kind: 'retry', reason, retryAfterMs: null };
|
||||
} finally {
|
||||
timer.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** 다시 하기 전에 쉰다 → 다시 해도 되면 true (시도 수 · 남은 시간 · 서버 종료). Retry-After 가 남은 시간을 넘으면 그만둔다 */
|
||||
private async pauseBeforeRetry(
|
||||
attempts: number,
|
||||
retryAfter: number | null,
|
||||
deadline: number,
|
||||
): Promise<boolean> {
|
||||
if (attempts >= FCM_SEND_ATTEMPTS || this.stop.signal.aborted) {
|
||||
return false;
|
||||
}
|
||||
const base =
|
||||
FCM_RETRY_DELAYS_MS[attempts - 1] ??
|
||||
FCM_RETRY_DELAYS_MS[FCM_RETRY_DELAYS_MS.length - 1];
|
||||
const delay =
|
||||
retryAfter ??
|
||||
Math.round(base * (1 + (this.random() * 2 - 1) * FCM_RETRY_JITTER));
|
||||
if (Date.now() + delay >= deadline) return false;
|
||||
await sleep(delay, this.stop.signal);
|
||||
return !this.stop.signal.aborted;
|
||||
}
|
||||
|
||||
/** 우리 설정 · 메시지가 틀렸다 — error, 종류마다 한 시간에 한 번 */
|
||||
private reportConfig(reason: string): void {
|
||||
const suppressed = this.failureLog.take(`config:${reason}`);
|
||||
if (suppressed === null) return;
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 알림을 보낼 수 없습니다 — FCM 설정(서비스 계정 키 · 권한 · 프로젝트)을 확인해 주세요: reason=${reason}${suppressedText(suppressed)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 그만둔다 → failed — 서버 종료로 끊긴 것이면 로그 없이 (배포마다 남는 잡음이 되지 않게), 아니면 warn */
|
||||
private giveUp(reason: string, attempts: number): PushSendOutcome {
|
||||
if (!this.stop.signal.aborted) this.reportGaveUp(reason, attempts);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
/** 잠깐의 장애가 끝나지 않았다 — warn, 종류마다 한 시간에 한 번 */
|
||||
private reportGaveUp(reason: string, attempts: number): void {
|
||||
const suppressed = this.failureLog.take(`gave-up:${reason}`);
|
||||
if (suppressed === null) return;
|
||||
this.logger.warn(
|
||||
`[SYS_001] 앱 알림을 보내지 못했습니다: reason=${reason}, attempts=${attempts}${suppressedText(suppressed)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function suppressedText(count: number): string {
|
||||
return count > 0 ? ` (앞 로그 뒤로 같은 실패 ${count}건 더)` : '';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 제한 시간이 있는 끊기 신호 — 시간이 다 되거나 부모 신호(서버 종료)가 끊기면 끊긴다.
|
||||
* 타이머는 프로세스를 붙잡지 않는다(unref). 다 쓰면 `release()` 로 타이머 · 듣기를 치운다.
|
||||
* AbortSignal.timeout 을 쓰지 않는 까닭: 시험의 가짜 시계(jest)로 시간을 넘기려고 — 전역 setTimeout 을 쓴다
|
||||
*/
|
||||
export interface DeadlineSignal {
|
||||
readonly signal: AbortSignal;
|
||||
/** 시간이 다 되어 끊겼는가 (부모가 끊은 것과 가른다) */
|
||||
readonly timedOut: () => boolean;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export function deadlineSignal(
|
||||
timeoutMs: number,
|
||||
parent: AbortSignal,
|
||||
): DeadlineSignal {
|
||||
const controller = new AbortController();
|
||||
let expired = false;
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
expired = true;
|
||||
controller.abort();
|
||||
},
|
||||
Math.max(0, timeoutMs),
|
||||
);
|
||||
timer.unref();
|
||||
const follow = () => controller.abort();
|
||||
if (parent.aborted) controller.abort();
|
||||
else parent.addEventListener('abort', follow, { once: true });
|
||||
return {
|
||||
signal: controller.signal,
|
||||
timedOut: () => expired,
|
||||
release: () => {
|
||||
clearTimeout(timer);
|
||||
parent.removeEventListener('abort', follow);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
import { FetchFcmTransport } from './fcm-transport';
|
||||
import { FCM_RESPONSE_MAX_BYTES } from './fcm.constants';
|
||||
|
||||
interface Received {
|
||||
readonly method: string | undefined;
|
||||
readonly url: string | undefined;
|
||||
readonly headers: IncomingMessage['headers'];
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
/** 127.0.0.1 의 가짜 Google — 받은 요청을 모으고 `respond` 로 답한다 */
|
||||
async function fakeGoogle(
|
||||
respond: (request: Received, response: ServerResponse) => void,
|
||||
): Promise<{ server: Server; base: string; received: Received[] }> {
|
||||
const received: Received[] = [];
|
||||
const server = createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
const entry: Received = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
};
|
||||
received.push(entry);
|
||||
respond(entry, response);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
return { server, base: `http://127.0.0.1:${port}`, received };
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
server.closeAllConnections();
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
|
||||
describe('FetchFcmTransport (FCM 의 HTTP — 시험은 127.0.0.1 의 가짜 서버로)', () => {
|
||||
it('토큰 교환은 form 으로 · 보내기는 /v1/projects/{id}/messages:send 에 Bearer · JSON 으로 — Retry-After 를 그대로 넘긴다', async () => {
|
||||
const google = await fakeGoogle((request, response) => {
|
||||
if (request.url === '/token') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end('{"access_token":"ya29.t","expires_in":3599}');
|
||||
return;
|
||||
}
|
||||
response.writeHead(503, { 'retry-after': '7' });
|
||||
response.end('{}');
|
||||
});
|
||||
try {
|
||||
const transport = FetchFcmTransport.forTesting({
|
||||
tokenUrl: `${google.base}/token`,
|
||||
sendBaseUrl: google.base,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const token = await transport.requestToken(
|
||||
'grant_type=x&assertion=y',
|
||||
signal,
|
||||
);
|
||||
expect(token).toEqual({
|
||||
status: 200,
|
||||
retryAfter: null,
|
||||
body: '{"access_token":"ya29.t","expires_in":3599}',
|
||||
});
|
||||
const sent = await transport.sendMessage(
|
||||
'arasystem-push-test',
|
||||
'ya29.t',
|
||||
'{"message":{}}',
|
||||
signal,
|
||||
);
|
||||
expect(sent).toEqual({ status: 503, retryAfter: '7', body: '{}' });
|
||||
|
||||
const [tokenRequest, sendRequest] = google.received;
|
||||
expect(tokenRequest).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/token',
|
||||
body: 'grant_type=x&assertion=y',
|
||||
});
|
||||
expect(tokenRequest.headers['content-type']).toBe(
|
||||
'application/x-www-form-urlencoded',
|
||||
);
|
||||
expect(sendRequest).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/v1/projects/arasystem-push-test/messages:send',
|
||||
body: '{"message":{}}',
|
||||
});
|
||||
expect(sendRequest.headers.authorization).toBe('Bearer ya29.t');
|
||||
expect(sendRequest.headers['content-type']).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
} finally {
|
||||
await close(google.server);
|
||||
}
|
||||
});
|
||||
|
||||
it(`응답 본문은 ${FCM_RESPONSE_MAX_BYTES / 1024}KB 까지만 읽는다 · 다른 곳으로 보내는 응답(3xx)은 따라가지 않는다`, async () => {
|
||||
const google = await fakeGoogle((request, response) => {
|
||||
if (request.url === '/token') {
|
||||
response.writeHead(302, { location: 'http://127.0.0.1:1/elsewhere' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(400, { 'content-type': 'application/json' });
|
||||
response.end('x'.repeat(FCM_RESPONSE_MAX_BYTES * 3));
|
||||
});
|
||||
try {
|
||||
const transport = FetchFcmTransport.forTesting({
|
||||
tokenUrl: `${google.base}/token`,
|
||||
sendBaseUrl: google.base,
|
||||
});
|
||||
const signal = new AbortController().signal;
|
||||
const sent = await transport.sendMessage('p-123456', 't', '{}', signal);
|
||||
expect(sent.status).toBe(400);
|
||||
expect(Buffer.byteLength(sent.body)).toBe(FCM_RESPONSE_MAX_BYTES);
|
||||
|
||||
const redirected = await transport.requestToken('a=b', signal);
|
||||
expect(redirected.status).toBe(302);
|
||||
expect(google.received).toHaveLength(2);
|
||||
} finally {
|
||||
await close(google.server);
|
||||
}
|
||||
});
|
||||
|
||||
it('끊기 신호가 오면 기다리지 않고 실패한다', async () => {
|
||||
const google = await fakeGoogle(() => {
|
||||
// 답하지 않는다
|
||||
});
|
||||
try {
|
||||
const transport = FetchFcmTransport.forTesting({
|
||||
sendBaseUrl: google.base,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = transport.sendMessage(
|
||||
'p-123456',
|
||||
't',
|
||||
'{}',
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
await expect(pending).rejects.toThrow();
|
||||
} finally {
|
||||
await close(google.server);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
FCM_OAUTH_TOKEN_URL,
|
||||
FCM_RESPONSE_MAX_BYTES,
|
||||
FCM_SEND_BASE_URL,
|
||||
} from './fcm.constants';
|
||||
|
||||
/**
|
||||
* FCM 의 HTTP 두 가지 — OAuth 토큰 교환 · 메시지 보내기 (fcm-auth.ts · fcm-sender.ts 가 주입받는다).
|
||||
* 앱은 전역 fetch 로 Google 의 두 주소(443)에만 간다. 단위 시험은 가짜를, 이 파일의 시험은 127.0.0.1 에 띄운 서버를 쓴다 —
|
||||
* 그래서 시험 전용 자리(forTesting)가 있다. 앱(Nest 의 useClass)은 쓰지 않는다: 주소는 상수이고 env · 설정으로 열지 않는다
|
||||
*/
|
||||
export const FCM_TRANSPORT = Symbol('FCM_TRANSPORT');
|
||||
|
||||
/** 받은 응답 — 본문은 FCM_RESPONSE_MAX_BYTES 까지만 읽는다 (오류 모양을 가르는 데만 쓴다) */
|
||||
export interface FcmHttpResponse {
|
||||
readonly status: number;
|
||||
/** Retry-After 머리 그대로 — 없으면 null */
|
||||
readonly retryAfter: string | null;
|
||||
readonly body: string;
|
||||
}
|
||||
|
||||
export interface FcmTransport {
|
||||
/** OAuth 토큰 교환 — form 은 `application/x-www-form-urlencoded` 본문 */
|
||||
requestToken(form: string, signal: AbortSignal): Promise<FcmHttpResponse>;
|
||||
/** `POST /v1/projects/{projectId}/messages:send` — body 는 `{ message }` JSON */
|
||||
sendMessage(
|
||||
projectId: string,
|
||||
accessToken: string,
|
||||
body: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FcmHttpResponse>;
|
||||
}
|
||||
|
||||
/** 가는 곳 — 앱은 Google 의 두 주소 고정 */
|
||||
export interface FcmEndpoints {
|
||||
readonly tokenUrl: string;
|
||||
readonly sendBaseUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_ENDPOINTS: FcmEndpoints = Object.freeze({
|
||||
tokenUrl: FCM_OAUTH_TOKEN_URL,
|
||||
sendBaseUrl: FCM_SEND_BASE_URL,
|
||||
});
|
||||
|
||||
/** 본문을 maxBytes 까지만 읽는다 — 넘는 뒤는 버리고 연결을 닫는다(UTF-8 이 중간에서 잘려도 오류 모양을 보는 데는 상관없다) */
|
||||
async function readLimited(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<string> {
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
try {
|
||||
while (received < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const room = maxBytes - received;
|
||||
const chunk = value.byteLength > room ? value.subarray(0, room) : value;
|
||||
chunks.push(chunk);
|
||||
received += chunk.byteLength;
|
||||
}
|
||||
} finally {
|
||||
// 다 읽지 않았으면 나머지를 받지 않는다 (다 읽었으면 아무 일도 하지 않는다)
|
||||
await reader.cancel().catch(() => undefined);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 fetch 로 보낸다 — 다른 곳으로 보내는 응답(3xx)은 따라가지 않는다(Authorization 머리가 다른 호스트로 가지 않게 — 부른 쪽이 설정 오류로 본다)
|
||||
*/
|
||||
@Injectable()
|
||||
export class FetchFcmTransport implements FcmTransport {
|
||||
private endpoints: FcmEndpoints = DEFAULT_ENDPOINTS;
|
||||
|
||||
/** 시험 전용 — 127.0.0.1 에 띄운 가짜 서버로 보낸다 (앱은 Google 주소 고정) */
|
||||
static forTesting(endpoints: Partial<FcmEndpoints>): FetchFcmTransport {
|
||||
const transport = new FetchFcmTransport();
|
||||
transport.endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints };
|
||||
return transport;
|
||||
}
|
||||
|
||||
requestToken(form: string, signal: AbortSignal): Promise<FcmHttpResponse> {
|
||||
return this.post(
|
||||
this.endpoints.tokenUrl,
|
||||
{ 'content-type': 'application/x-www-form-urlencoded' },
|
||||
form,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
projectId: string,
|
||||
accessToken: string,
|
||||
body: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FcmHttpResponse> {
|
||||
return this.post(
|
||||
`${this.endpoints.sendBaseUrl}/v1/projects/${encodeURIComponent(projectId)}/messages:send`,
|
||||
{
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
private async post(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FcmHttpResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
retryAfter: response.headers.get('retry-after'),
|
||||
body: await readLimited(response, FCM_RESPONSE_MAX_BYTES),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* FCM HTTP v1 보내기의 값 — 주소 · 시간 · 메시지 칸 (계약 1절 "앱 새 메일 알림 · 앱 설정"의 "푸시 모양").
|
||||
* 주소는 상수다 — 키 파일의 token_uri 는 쓰지 않는다(같은 값인지만 본다). 시험만 forTesting 으로 바꾼다(fcm-transport.ts — env · 설정으로는 열지 않는다)
|
||||
*/
|
||||
|
||||
/** OAuth 토큰 교환 주소 (서비스 계정 JWT → access token) */
|
||||
export const FCM_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
/** 보내기 주소의 앞 — `/v1/projects/{projectId}/messages:send` 를 붙인다 */
|
||||
export const FCM_SEND_BASE_URL = 'https://fcm.googleapis.com';
|
||||
/** 보내기 권한 범위 */
|
||||
export const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
|
||||
|
||||
/** 서비스 계정 JWT 의 수명(초) — Google 이 받는 최대 1시간 */
|
||||
export const FCM_ASSERTION_LIFETIME_SECONDS = 3_600;
|
||||
/** access token 을 만료 이만큼 전까지만 쓴다 — 보내는 사이 끝나지 않게 */
|
||||
export const FCM_ACCESS_TOKEN_REFRESH_MARGIN_MS = 5 * 60_000;
|
||||
/** access token 의 모양 — 공백 없는 글 · 이 길이까지 (Google 은 수백 자) */
|
||||
export const FCM_ACCESS_TOKEN_MAX_LENGTH = 4_096;
|
||||
|
||||
/** HTTP 한 번(토큰 교환 · 보내기 시도)의 제한 시간 */
|
||||
export const FCM_REQUEST_TIMEOUT_MS = 10_000;
|
||||
/** 기기 하나에 보내기 한 번 — 다시 하기까지 이 안에서 */
|
||||
export const FCM_SEND_DEADLINE_MS = 20_000;
|
||||
/** 시도 수 — 잠깐의 장애(429 · 5xx · 연결 오류 · 시간 초과)만 다시 한다 */
|
||||
export const FCM_SEND_ATTEMPTS = 3;
|
||||
/** 다시 하기 전 쉬는 시간 — 첫 실패 뒤 1초, 두 번째 뒤 4초 (± 흔들림). 응답의 Retry-After 가 있으면 그것을 따른다(남은 시간 안에서만) */
|
||||
export const FCM_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000];
|
||||
/** 쉬는 시간의 흔들림 (±20%) — 장애가 풀린 순간 모두 함께 다시 보내지 않게 */
|
||||
export const FCM_RETRY_JITTER = 0.2;
|
||||
/** 응답 본문은 이만큼까지만 읽는다 (오류 모양을 가르는 데만 쓴다) */
|
||||
export const FCM_RESPONSE_MAX_BYTES = 64 * 1024;
|
||||
/** 서버 전체에서 함께 보내는 수 — 새 메일이 여러 직원에게 한꺼번에 와도 */
|
||||
export const FCM_SEND_CONCURRENCY = 4;
|
||||
/** 설정 오류 · 끝내 못 보냄 로그는 종류마다 이 간격에 한 번 (사이에 묻힌 수를 함께 적는다) */
|
||||
export const FCM_FAILURE_LOG_INTERVAL_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* 서비스 계정 키 env — backend/.env.{env} (JWT_SECRET 과 같은 자리 — compose 의 env_file). 키 JSON 파일을 통째로 base64 한 줄.
|
||||
* 비면 앱 알림을 끈다(기동은 한다). 모양은 env 검사 · 보내는 쪽이 같은 함수(fcm-credentials.ts)로 본다
|
||||
*/
|
||||
export const FCM_SERVICE_ACCOUNT_KEY_ENV = 'FCM_SERVICE_ACCOUNT_KEY';
|
||||
/** 서비스 계정 키 env 의 길이 상한 — 키 파일(2KB 남짓)의 base64 에 넉넉히 */
|
||||
export const FCM_SERVICE_ACCOUNT_KEY_MAX_LENGTH = 16 * 1024;
|
||||
|
||||
/** 메시지 칸 (계약의 "푸시 모양") — 안드로이드 알림 하나를 같은 tag 로 바꿔 쓴다 */
|
||||
export const FCM_NOTIFICATION_TAG = 'new-mail';
|
||||
export const FCM_COLLAPSE_KEY = 'new-mail';
|
||||
/** 앱이 만드는 알림 채널 id (중요도 높음 · 이름 "새 메일") */
|
||||
export const FCM_CHANNEL_ID = 'new_mail';
|
||||
/** 꺼져 있던 휴대폰이 이 안에 켜지면 마지막 것 하나를 받는다 — 넘으면 버린다 */
|
||||
export const FCM_TTL = '86400s';
|
||||
/** 누르면 갈 모듈 (앱 README — 알림에는 모듈 이름과 알림 종류만 싣는다) */
|
||||
export const PUSH_MODULE_MAIL = 'mail';
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
SESSION_ENDED_NOTICE,
|
||||
SESSION_ENDED_NOTICE_REASONS,
|
||||
} from './push.constants';
|
||||
import { PushCleanupService } from './push-cleanup.service';
|
||||
import type {
|
||||
EndedPushRegistration,
|
||||
PushRegistrationsRepository,
|
||||
} from './push-registrations.repository';
|
||||
import type { PushRegistrationsService } from './push-registrations.service';
|
||||
import type { PushNotice, PushSender } from './push-sender.port';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
|
||||
const ended = (
|
||||
overrides: Partial<EndedPushRegistration>,
|
||||
): EndedPushRegistration => ({
|
||||
sessionId: '0b6f4a1e-7c43-4c39-9a51-0d2f3c7b8e10',
|
||||
userEmail: ARA,
|
||||
fid: 'fid-ara-phone',
|
||||
notify: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function setup(
|
||||
rows: EndedPushRegistration[] | Error,
|
||||
options: {
|
||||
available?: boolean;
|
||||
domain?: string | null;
|
||||
/** 그사이 다른 등록이 쓰고 있는 FID (그 휴대폰에서 다른 직원이 로그인했다) */
|
||||
takenFids?: string[];
|
||||
} = {},
|
||||
) {
|
||||
const taken = new Set(options.takenFids ?? []);
|
||||
const repository = {
|
||||
takeEnded: jest.fn(() =>
|
||||
rows instanceof Error ? Promise.reject(rows) : Promise.resolve(rows),
|
||||
),
|
||||
isFidFree: jest.fn((fid: string) => Promise.resolve(!taken.has(fid))),
|
||||
};
|
||||
const registrations = { notifyChanged: jest.fn() };
|
||||
const sent: { fid: string; notice: PushNotice }[] = [];
|
||||
const sender: PushSender = {
|
||||
available: options.available ?? true,
|
||||
// 보내는 쪽처럼 — 보내기 직전에 부른 쪽의 확인을 본다
|
||||
send: async (fid, notice, sendOptions) => {
|
||||
if (sendOptions?.stillValid && !(await sendOptions.stillValid())) {
|
||||
return 'skipped';
|
||||
}
|
||||
sent.push({ fid, notice });
|
||||
return 'sent';
|
||||
},
|
||||
};
|
||||
const service = new PushCleanupService(
|
||||
repository as unknown as PushRegistrationsRepository,
|
||||
registrations as unknown as PushRegistrationsService,
|
||||
sender,
|
||||
{
|
||||
domain: options.domain === undefined ? 'arasystem.test' : options.domain,
|
||||
},
|
||||
);
|
||||
return { service, repository, registrations, sent };
|
||||
}
|
||||
|
||||
describe('세션 끝 알림을 보내는 까닭 (SESSION_ENDED_NOTICE — 표 한 곳)', () => {
|
||||
it('그 휴대폰을 직원이 건드리지 않고 끝난 까닭만 보낸다 — 다른 곳의 비밀번호 바꾸기 · 결과 모름 · 앱 갱신 유예 · 토큰 재사용', () => {
|
||||
expect([...SESSION_ENDED_NOTICE_REASONS].sort()).toEqual([
|
||||
'app-refresh-grace',
|
||||
'password-change-uncertain',
|
||||
'password-changed',
|
||||
'refresh-token-reused',
|
||||
]);
|
||||
});
|
||||
|
||||
it('직원 · 운영자가 끊었거나 계정을 쓸 수 없거나 제한된 세션이면 보내지 않는다 — 로그아웃 · 기기 끊기 · 운영 도구 · 계정 문제 · 2단계 켜기', () => {
|
||||
const silent = Object.entries(SESSION_ENDED_NOTICE)
|
||||
.filter(([, notify]) => !notify)
|
||||
.map(([reason]) => reason)
|
||||
.sort();
|
||||
expect(silent).toEqual([
|
||||
'account-unavailable',
|
||||
'ended-by-operator',
|
||||
'ended-by-user',
|
||||
'logout',
|
||||
'two-factor-not-verified',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PushCleanupService (끝난 세션의 앱 알림 등록 정리 — 5분마다)', () => {
|
||||
/** 지운 수는 debug — 시험 출력에 섞지 않고 모아서 본다(FID 가 없는지) */
|
||||
let debugLines: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
debugLines = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'debug')
|
||||
.mockImplementation((message: unknown) => {
|
||||
debugLines.push(String(message));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('사내 도메인의 끝난 세션 줄을 지우고, 알릴 세션(notify — 저절로 끝남 · 보내는 까닭)의 휴대폰에만 한 번 알린다(session-ended) — 등록이 바뀐 직원을 알린다', async () => {
|
||||
const now = new Date('2026-11-07T05:00:00Z');
|
||||
const { service, repository, registrations, sent } = setup([
|
||||
ended({ fid: 'fid-expired' }),
|
||||
ended({ fid: 'fid-revoked', notify: false }),
|
||||
ended({ userEmail: DOYUN, fid: 'fid-doyun', notify: true }),
|
||||
]);
|
||||
|
||||
await expect(service.sweep(now)).resolves.toEqual({
|
||||
removed: 3,
|
||||
notified: 2,
|
||||
});
|
||||
expect(repository.takeEnded).toHaveBeenCalledWith('@arasystem.test', now);
|
||||
expect(sent).toEqual([
|
||||
{ fid: 'fid-expired', notice: { kind: 'session-ended' } },
|
||||
{ fid: 'fid-doyun', notice: { kind: 'session-ended' } },
|
||||
]);
|
||||
expect(registrations.notifyChanged.mock.calls).toEqual([[ARA], [DOYUN]]);
|
||||
// 로그는 수만 — FID 는 남기지 않는다
|
||||
expect(debugLines).toHaveLength(1);
|
||||
expect(debugLines[0]).not.toMatch(/fid-/);
|
||||
});
|
||||
|
||||
it('알리기 직전 그 휴대폰(FID)을 다른 등록이 쓰고 있으면 알리지 않는다 — 그 휴대폰에서 로그인한 다른 직원에게 "로그인이 끝났다"가 가지 않게', async () => {
|
||||
const { service, repository, sent } = setup(
|
||||
[ended({ fid: 'fid-taken' }), ended({ fid: 'fid-free' })],
|
||||
{ takenFids: ['fid-taken'] },
|
||||
);
|
||||
await expect(service.sweep()).resolves.toEqual({ removed: 2, notified: 1 });
|
||||
expect(repository.isFidFree.mock.calls).toEqual([
|
||||
['fid-taken'],
|
||||
['fid-free'],
|
||||
]);
|
||||
expect(sent.map(({ fid }) => fid)).toEqual(['fid-free']);
|
||||
});
|
||||
|
||||
it('FCM 키가 없으면 지우기만 한다', async () => {
|
||||
const { service, sent } = setup([ended({})], { available: false });
|
||||
await expect(service.sweep()).resolves.toEqual({ removed: 1, notified: 0 });
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
it('지울 것이 없으면 아무것도 보내지 않는다 · 사내 도메인 설정이 없으면 표를 묻지도 않는다', async () => {
|
||||
const empty = setup([]);
|
||||
await expect(empty.service.sweep()).resolves.toEqual({
|
||||
removed: 0,
|
||||
notified: 0,
|
||||
});
|
||||
expect(empty.registrations.notifyChanged).not.toHaveBeenCalled();
|
||||
|
||||
const noDomain = setup([ended({})], { domain: null });
|
||||
await expect(noDomain.service.sweep()).resolves.toEqual({
|
||||
removed: 0,
|
||||
notified: 0,
|
||||
});
|
||||
expect(noDomain.repository.takeEnded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('정리가 실패하면 error 한 줄(종류만 — DB 문장의 값은 남기지 않는다) — 다음 차례에 다시 한다', async () => {
|
||||
const errors: string[] = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
const failure = Object.assign(new Error(`relation broke near ${ARA}`), {
|
||||
code: '57P01',
|
||||
});
|
||||
const { service } = setup(failure);
|
||||
|
||||
await expect(service.sweep()).resolves.toEqual({ removed: 0, notified: 0 });
|
||||
expect(errors).toEqual([
|
||||
expect.stringContaining('Error/57P01') as unknown as string,
|
||||
]);
|
||||
expect(errors[0]).not.toContain(ARA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import { PUSH_CLEANUP_CRON, PUSH_CLEANUP_JOB } from './push.constants';
|
||||
import { PushRegistrationsRepository } from './push-registrations.repository';
|
||||
import { PushRegistrationsService } from './push-registrations.service';
|
||||
import { PUSH_SENDER, type PushSender } from './push-sender.port';
|
||||
|
||||
/** 한 번의 정리 결과 (시험 · 로그) */
|
||||
export interface PushCleanupResult {
|
||||
readonly removed: number;
|
||||
/** 로그인이 끝났다고 알린 휴대폰 수 (세션 끝 알림 — 보내는 까닭은 SESSION_ENDED_NOTICE) */
|
||||
readonly notified: number;
|
||||
}
|
||||
|
||||
/** DB 오류의 종류만 — 문장에는 값(주소 등)이 들어갈 수 있다 */
|
||||
function failureSummary(error: unknown): string {
|
||||
const name = error instanceof Error ? error.name : typeof error;
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return typeof code === 'string' ? `${name}/${code}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 세션의 앱 알림 등록 정리 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 5분마다.
|
||||
* - 끊긴 세션(로그아웃 · 로그인된 기기에서 끊기 · 비밀번호 바꾸기 · 운영 도구) · 끝난 세션 · 세션 줄이 없는 등록을 지운다.
|
||||
* 보낼 때는 이미 살아 있는 세션만 고르므로(push-registrations.repository.ts) 정리가 늦어도 틀리지 않는다
|
||||
* - **그 휴대폰을 직원이 건드리지 않았는데** 끝난 세션 — 저절로 끝남(미사용 14일 · 절대 30일) · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지,
|
||||
* 제한 없던 세션만(까닭 표 push.constants.ts 의 SESSION_ENDED_NOTICE) — 의 휴대폰에는 지운 뒤 한 번 알린다(session-ended — 같은 tag 로
|
||||
* 옛 "새 메일 N통"을 바꿔 쓴다). 직원 · 운영자가 끊었거나 계정을 쓸 수 없으면 알리지 않는다.
|
||||
* 보내기 직전 그 FID 를 다른 등록이 쓰고 있으면(그 휴대폰에서 다른 직원이 로그인했다) 알리지 않는다.
|
||||
* 보내지 못해도 다시 하지 않는다(줄은 이미 없다). FCM 키가 없으면 지우기만 한다
|
||||
* - 사내 도메인(MAIL_DOMAIN) 주소만 — 같은 DB 의 다른 도메인 줄(e2e 등)은 그쪽 서버가 한다
|
||||
* 반복되는 정상 흐름이라 지운 수는 debug 로만 남긴다. 실패는 error 한 줄 — 다음 차례에 다시 한다
|
||||
*
|
||||
* ⚠️ 인스턴스를 늘려도 알림은 겹치지 않는다 — 지운 인스턴스만 그 줄을 받는다(DELETE … RETURNING)
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushCleanupService {
|
||||
private readonly logger = new Logger(PushCleanupService.name);
|
||||
/** 정리하는 주소의 끝 — `@사내 도메인` (설정이 없으면 null — 아무것도 하지 않는다) */
|
||||
private readonly addressSuffix: string | null;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly repository: PushRegistrationsRepository,
|
||||
private readonly registrations: PushRegistrationsService,
|
||||
@Inject(PUSH_SENDER) private readonly sender: PushSender,
|
||||
@Inject(MAIL_SETTINGS) settings: Pick<MailSettings, 'domain'>,
|
||||
) {
|
||||
const domain = settings.domain?.trim().toLowerCase();
|
||||
this.addressSuffix = domain ? `@${domain}` : null;
|
||||
}
|
||||
|
||||
@Cron(PUSH_CLEANUP_CRON, { name: PUSH_CLEANUP_JOB })
|
||||
async sweep(now: Date = new Date()): Promise<PushCleanupResult> {
|
||||
if (this.running || this.addressSuffix === null) {
|
||||
return { removed: 0, notified: 0 };
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const ended = await this.repository.takeEnded(this.addressSuffix, now);
|
||||
if (ended.length === 0) return { removed: 0, notified: 0 };
|
||||
|
||||
const notices = this.sender.available
|
||||
? ended.filter((row) => row.notify)
|
||||
: [];
|
||||
// 보내기 직전 그 휴대폰에서 다른 직원이 등록했으면 알리지 않는다 — 그 직원에게 "로그인이 끝났다"가 가지 않게
|
||||
const outcomes = await Promise.all(
|
||||
notices.map((row) =>
|
||||
this.sender.send(
|
||||
row.fid,
|
||||
{ kind: 'session-ended' },
|
||||
{ stillValid: () => this.repository.isFidFree(row.fid) },
|
||||
),
|
||||
),
|
||||
);
|
||||
const notified = outcomes.filter((outcome) => outcome === 'sent').length;
|
||||
|
||||
for (const user of new Set(ended.map((row) => row.userEmail))) {
|
||||
this.registrations.notifyChanged(user);
|
||||
}
|
||||
this.logger.debug(
|
||||
`끝난 세션의 앱 알림 등록 ${ended.length}건을 지웠습니다 (로그인이 끝났다고 알림 ${notified}건).`,
|
||||
);
|
||||
return { removed: ended.length, notified };
|
||||
} catch (error) {
|
||||
// 정리가 실패해도 앱은 계속 돈다 — 다음 차례에 다시 한다
|
||||
this.logger.error(
|
||||
`[SYS_001] 끝난 세션의 앱 알림 등록을 정리하지 못했습니다: ${failureSummary(error)}`,
|
||||
);
|
||||
return { removed: 0, notified: 0 };
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import {
|
||||
PUSH_FID_MAX_LENGTH,
|
||||
PUSH_REGISTRATIONS_TABLE,
|
||||
type PushPlatform,
|
||||
} from './push.constants';
|
||||
|
||||
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — auth-session.entity.ts 와 같다 */
|
||||
const EMAIL_COLUMN_LENGTH = 254;
|
||||
/** 플랫폼 이름 길이 — 'android' (여유를 둔다) */
|
||||
const PLATFORM_COLUMN_LENGTH = 16;
|
||||
/** 앱 판 길이 — 'NNNN.NNNN.NNNN' 까지 (push.constants 의 APP_VERSION_MAX_LENGTH 에 여유) */
|
||||
const APP_VERSION_COLUMN_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 앱 세션(sid)마다 한 줄, 그 세션의 휴대폰 주소(FID)를 둔다.
|
||||
*
|
||||
* - 같은 FID 는 한 줄에만 있다(유일 색인) — 같은 휴대폰에서 다른 직원이 로그인하면 앞 직원의 줄을 지운다(그 알림이 다른 사람 휴대폰으로 가지 않게)
|
||||
* - 세션 표와 외래 키로 묶지 않는다 — 두 모듈의 정리 작업이 서로를 기다리지 않게(share-link-file.entity.ts 와 같은 결).
|
||||
* 보낼 때는 살아 있는 · 제한 없는 앱 세션의 줄만 고르고, 끝난 세션의 줄은 5분 정리(push-cleanup.service.ts)가 지운다
|
||||
* - 켬 · 끔은 휴대폰이 들고 있다 — 끄면 앱이 줄을 지운다(DELETE). 서버의 칸에는 없다
|
||||
* - FID 원문은 보낼 때만 쓴다 — 로그에 남기지 않는다(지문도 — 이어 붙여 기기를 좇지 못하게)
|
||||
*/
|
||||
@Entity({ name: PUSH_REGISTRATIONS_TABLE })
|
||||
export class PushRegistrationEntity {
|
||||
/** 등록한 앱 세션 id (auth_sessions.id — 액세스 토큰의 sid) */
|
||||
@PrimaryColumn({ name: 'session_id', type: 'uuid' })
|
||||
sessionId!: string;
|
||||
|
||||
/** 직원 주소 — 세션 줄의 주소를 그대로 옮겨 적는다. 직원마다 읽기 · 상한 · 퇴사 마무리가 이 칸으로 찾는다 */
|
||||
@Index('IDX_push_registrations_user_email')
|
||||
@Column({ name: 'user_email', type: 'varchar', length: EMAIL_COLUMN_LENGTH })
|
||||
userEmail!: string;
|
||||
|
||||
/** 휴대폰 주소 — Firebase 설치 id(FID). FCM v1 의 `message.fid` 로 보낸다 */
|
||||
@Index('UQ_push_registrations_fid', { unique: true })
|
||||
@Column({ type: 'varchar', length: PUSH_FID_MAX_LENGTH })
|
||||
fid!: string;
|
||||
|
||||
/** 플랫폼 — 지금은 android 만 */
|
||||
@Column({ type: 'varchar', length: PLATFORM_COLUMN_LENGTH })
|
||||
platform!: PushPlatform;
|
||||
|
||||
/** 앱 판 (`1.0.0`) — 문의 대응 · 옛 판 확인 */
|
||||
@Column({
|
||||
name: 'app_version',
|
||||
type: 'varchar',
|
||||
length: APP_VERSION_COLUMN_LENGTH,
|
||||
})
|
||||
appVersion!: string;
|
||||
|
||||
/**
|
||||
* 이 휴대폰에서 마지막으로 앱을 본 때 — 알림의 N(받은편지함의 안 읽은 메일 가운데 이 뒤에 받은 수)을 세는 시작.
|
||||
* 처음 등록할 때 지금, 앱이 `DELETE …/current/unseen` 을 부르면 지금. 등록을 다시 해도(PUT) 바꾸지 않는다
|
||||
*/
|
||||
@Column({ name: 'seen_at', type: 'timestamptz' })
|
||||
seenAt!: Date;
|
||||
|
||||
/** 마지막으로 보낸 때(그 알림의 N 을 센 때) — 서버를 다시 띄울 때 그사이 온 메일을 한 번 알리되, 이미 알린 메일은 다시 알리지 않는다 */
|
||||
@Column({ name: 'last_pushed_at', type: 'timestamptz', nullable: true })
|
||||
lastPushedAt!: Date | null;
|
||||
|
||||
@Column({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 마지막으로 등록(PUT)한 때 — 직원마다 상한을 넘으면 이것이 가장 오래된 줄부터 지운다 */
|
||||
@Column({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
ApiErrorResponse,
|
||||
ApiStandardResponse,
|
||||
} from '@/common/decorators/api-standard-response.decorator';
|
||||
import {
|
||||
type AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '@/common/decorators/current-user.decorator';
|
||||
import { NoStore } from '@/common/decorators/no-store.decorator';
|
||||
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
|
||||
import { AppClientGuard } from '@/common/guards/app-client.guard';
|
||||
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
|
||||
import {
|
||||
PushRegistrationDto,
|
||||
PushRegistrationResultDto,
|
||||
} from './dto/push-registration.dto';
|
||||
import { MAX_PUSH_REGISTRATIONS_PER_USER } from './push.constants';
|
||||
import { PushRegistrationsService } from './push-registrations.service';
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 앱 토큰(Bearer)만. 지금 세션(액세스 토큰의 sid)의 등록 하나를 가리킨다(`current`).
|
||||
* 제한된 세션은 부를 수 없다(@AllowRestrictedSession 을 달지 않는다 — 403 AUTH_010). 웹 쿠키 세션은 403 AUTH_003(AppClientGuard).
|
||||
* 판단은 모두 PushRegistrationsService 가 한다. FID 는 로그에 남기지 않는다
|
||||
*/
|
||||
@ApiTags('Push')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, AppClientGuard)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_001',
|
||||
'로그인 필요 (앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 그사이 끝난 세션) — 앱은 갱신(/auth/app/refresh)을 한 번 시도한다',
|
||||
)
|
||||
// 같은 403 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
|
||||
@ApiErrorResponse(
|
||||
'AUTH_003',
|
||||
'AUTH_003 웹(쿠키) 세션 — 앱 토큰만 받는다 / AUTH_010 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) (모두 403)',
|
||||
)
|
||||
@Controller('push-registrations')
|
||||
export class PushRegistrationsController {
|
||||
constructor(private readonly registrations: PushRegistrationsService) {}
|
||||
|
||||
@Put('current')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@NoStore()
|
||||
@ApiOperation({
|
||||
summary: '이 앱 세션의 알림 등록 — 휴대폰 주소(FID)를 넣거나 바꾼다',
|
||||
description:
|
||||
'세션마다 하나다. 같은 FID 의 다른 등록(같은 휴대폰에서 다른 직원이 로그인 · 다시 로그인)은 지운다. ' +
|
||||
`한 직원 ${MAX_PUSH_REGISTRATIONS_PER_USER}개까지 — 넘으면 가장 오래 바꾸지 않은 등록부터 지운다. ` +
|
||||
'새 등록이면 알림의 수(N)를 지금부터 세고, 있던 등록은 세는 시작을 바꾸지 않는다(봤음은 DELETE …/current/unseen). ' +
|
||||
'FCM 키가 없어도(app-config 의 pushAvailable: false) 저장한다. 앱은 FID 를 받았을 때 · 바뀌었을 때 · 등록된 채 앱을 띄울 때 한 번 · 알림을 켤 때 부른다.',
|
||||
})
|
||||
@ApiStandardResponse(PushRegistrationResultDto, {
|
||||
description: '등록함 (updatedAt)',
|
||||
})
|
||||
@ApiErrorResponse(
|
||||
'VAL_001',
|
||||
'본문 형식 오류 (fid · platform · appVersion 모양 · 모르는 칸)',
|
||||
)
|
||||
register(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() dto: PushRegistrationDto,
|
||||
): Promise<PushRegistrationResultDto> {
|
||||
return this.registrations.register(user, {
|
||||
fid: dto.fid,
|
||||
platform: dto.platform,
|
||||
appVersion: dto.appVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('current')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary: '이 앱 세션의 알림 등록을 지운다 (없어도 204)',
|
||||
description:
|
||||
'앱이 알림을 껐을 때 · 휴대폰에서 알림 권한이나 채널이 꺼진 것을 알았을 때 부른다. ' +
|
||||
'로그아웃 때는 부르지 않아도 된다 — 끝난 세션에는 보내지 않고 5분 정리가 지운다.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NO_CONTENT,
|
||||
description: '지움 — 본문 없음',
|
||||
})
|
||||
async unregister(@CurrentUser() user: AuthenticatedUser): Promise<void> {
|
||||
await this.registrations.unregister(user);
|
||||
}
|
||||
|
||||
@Delete('current/unseen')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@SkipTransform()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'이 휴대폰에서 앱을 봤다 — 다음 알림의 수(N)를 지금부터 센다 (없어도 204)',
|
||||
description:
|
||||
'N 은 받은편지함의 안 읽은 메일 가운데 이 휴대폰에서 마지막으로 앱을 본 뒤 받은 수다(서버 시각). ' +
|
||||
'앱은 앞으로 올 때 · 앞에 있는 동안 새 메일 소식이 올 때 부른다(30초에 한 번까지).',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.NO_CONTENT,
|
||||
description: '적음 — 본문 없음',
|
||||
})
|
||||
async markSeen(@CurrentUser() user: AuthenticatedUser): Promise<void> {
|
||||
await this.registrations.markSeen(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
APP_SESSION_CLIENT,
|
||||
AUTH_SESSIONS_TABLE,
|
||||
MAX_PUSH_REGISTRATIONS_PER_USER,
|
||||
PUSH_REGISTRATIONS_TABLE,
|
||||
type PushPlatform,
|
||||
SESSION_ENDED_NOTICE_REASONS,
|
||||
} from './push.constants';
|
||||
|
||||
/** 등록 한 번의 값 — 세션은 액세스 토큰의 sid */
|
||||
export interface PushRegistrationInput {
|
||||
readonly sessionId: string;
|
||||
readonly fid: string;
|
||||
readonly platform: PushPlatform;
|
||||
readonly appVersion: string;
|
||||
}
|
||||
|
||||
/** 등록한 줄 — 직원 주소는 세션 줄에서 옮겨 적은 값 */
|
||||
export interface PushRegistered {
|
||||
readonly userEmail: string;
|
||||
readonly updatedAt: Date;
|
||||
/** 이 등록 때문에 줄이 지워진 직원 — 같은 FID 의 다른 세션 · 상한을 넘은 줄 (등록이 바뀐 소식을 낸다) */
|
||||
readonly displacedUsers: readonly string[];
|
||||
}
|
||||
|
||||
/** 보낼 기기 하나 — 살아 있는 · 제한 없는 앱 세션의 등록 */
|
||||
export interface PushTarget {
|
||||
readonly sessionId: string;
|
||||
readonly fid: string;
|
||||
readonly seenAt: Date;
|
||||
readonly lastPushedAt: Date | null;
|
||||
}
|
||||
|
||||
/** 정리가 지운 등록 */
|
||||
export interface EndedPushRegistration {
|
||||
readonly sessionId: string;
|
||||
readonly userEmail: string;
|
||||
readonly fid: string;
|
||||
/**
|
||||
* 그 휴대폰에 한 번 알린다(session-ended) — 제한 없던 앱 세션이 저절로 끝났거나(미사용 14일 · 절대 30일 — 끊기지 않음)
|
||||
* 보내는 까닭(push.constants.ts 의 SESSION_ENDED_NOTICE)으로 끊겼다. 그 밖(직원 · 운영자가 끊음 · 계정 문제 · 제한 · 세션 줄 없음)은 false
|
||||
*/
|
||||
readonly notify: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 FID 의 등록을 차례대로 — `pg_advisory_xact_lock(이 값, hashtext(FID))` (트랜잭션이 끝나면 풀린다). 두 세션이 같은 FID 를 함께 넣어도
|
||||
* 뒤의 것이 앞의 줄을 보고 지운 뒤 넣는다(유일 색인에 걸리지 않게). 이름표 — 'PSH1' 의 ASCII (서명 'SIG1' · 파일 'FIL1' 과 섞이지 않게)
|
||||
*/
|
||||
const PUSH_FID_LOCK = 0x50534831;
|
||||
/** 그래도 유일 색인에 걸리면(잠금 밖에서 넣은 줄) 한 번 더 한다 */
|
||||
const REGISTER_ATTEMPTS = 2;
|
||||
/** PostgreSQL 의 unique_violation */
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
/**
|
||||
* 살아 있는 · 제한 없는 앱 세션 — 별칭 `s` 의 auth_sessions 줄, 지금은 `now` 매개변수 (보낼 대상 · 등록 · 붙잡을 직원이 같은 조건을 쓴다).
|
||||
* 끊은 세션은 revoked_at 으로 바로 빠진다 — 서버의 끊은 세션 기억(15초 동기화)과 상관없이, 운영 도구가 끊은 것도
|
||||
*/
|
||||
function aliveAppSession(now: string): string {
|
||||
return `s."client" = '${APP_SESSION_CLIENT}' AND s."revoked_at" IS NULL AND s."expires_at" > ${now} AND s."absolute_expires_at" > ${now} AND s."restriction" IS NULL`;
|
||||
}
|
||||
|
||||
/** 주소가 그 끝(`@사내 도메인` — 소문자)으로 끝나는가 (LIKE 의 특수 글자를 신경 쓰지 않게 right 로 견준다) */
|
||||
function endsWithSuffix(column: string, suffix: string): string {
|
||||
return `right(lower(${column}), char_length(${suffix})) = ${suffix}`;
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
const { code, driverError } = error as {
|
||||
code?: unknown;
|
||||
driverError?: { code?: unknown };
|
||||
};
|
||||
return code === UNIQUE_VIOLATION || driverError?.code === UNIQUE_VIOLATION;
|
||||
}
|
||||
|
||||
/** UPDATE · DELETE 의 결과는 [줄들, 바꾼 수] (TypeORM 의 postgres 드라이버) — INSERT 는 줄들 */
|
||||
type ChangedRows<T> = [T[], number];
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 표 접근 (push_registrations — 계약 1절 "앱 새 메일 알림 · 앱 설정"). 판단(누가 · 언제)은 서비스가 한다.
|
||||
* 세션 표(auth_sessions)는 읽기만 한다 — 외래 키는 없다(push-registration.entity.ts)
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushRegistrationsRepository {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* 등록 — 한 트랜잭션: ① 같은 FID 의 다른 줄(다른 세션 · 다른 직원)을 지운다 → ② 세션 줄이 살아 있는 · 제한 없는 앱 세션이면 넣거나 바꾼다
|
||||
* (FID · 플랫폼 · 판 · updated_at — 새 줄이면 seen_at = 지금, 있던 줄의 seen_at · last_pushed_at 은 그대로) → ③ 그 직원의 상한을 넘으면
|
||||
* 가장 오래 바꾸지 않은 줄부터 지운다. 세션이 그런 세션이 아니면(그사이 끝남 · 끊김 · 웹 · 제한) null — 아무것도 바꾸지 않는다
|
||||
*/
|
||||
async register(
|
||||
input: PushRegistrationInput,
|
||||
now: Date,
|
||||
): Promise<PushRegistered | null> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
return await this.dataSource.transaction((manager) =>
|
||||
this.registerOnce(manager, input, now),
|
||||
);
|
||||
} catch (error) {
|
||||
if (attempt < REGISTER_ATTEMPTS && isUniqueViolation(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록하지 못한 세션의 까닭 — 살아 있는 앱 세션인데 제한이 붙었으면 restricted(토큰을 받은 뒤 갱신에서 제한이 붙었다),
|
||||
* 그 밖(줄 없음 · 끝남 · 끊김 · 웹 세션)은 ended
|
||||
*/
|
||||
async refusalOf(
|
||||
sessionId: string,
|
||||
now: Date,
|
||||
): Promise<'restricted' | 'ended'> {
|
||||
const rows: unknown[] = await this.dataSource.query(
|
||||
`SELECT 1 FROM "${AUTH_SESSIONS_TABLE}" s
|
||||
WHERE s."id" = $1 AND s."client" = '${APP_SESSION_CLIENT}' AND s."revoked_at" IS NULL
|
||||
AND s."expires_at" > $2 AND s."absolute_expires_at" > $2 AND s."restriction" IS NOT NULL`,
|
||||
[sessionId, now],
|
||||
);
|
||||
return rows.length > 0 ? 'restricted' : 'ended';
|
||||
}
|
||||
|
||||
/** 세션의 등록을 지운다 → 지운 줄의 직원 주소 (없으면 null) */
|
||||
async remove(sessionId: string): Promise<string | null> {
|
||||
const [rows] = await this.dataSource.query<
|
||||
ChangedRows<{ user_email: string }>
|
||||
>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "session_id" = $1 RETURNING "user_email"`,
|
||||
[sessionId],
|
||||
);
|
||||
return rows[0]?.user_email ?? null;
|
||||
}
|
||||
|
||||
/** 이 휴대폰에서 앱을 봤다 — N 을 세는 시작을 지금으로 → 바꿨으면 true */
|
||||
async markSeen(sessionId: string, now: Date): Promise<boolean> {
|
||||
const [, changed] = await this.dataSource.query<ChangedRows<unknown>>(
|
||||
`UPDATE "${PUSH_REGISTRATIONS_TABLE}" SET "seen_at" = $2 WHERE "session_id" = $1`,
|
||||
[sessionId, now],
|
||||
);
|
||||
return changed > 0;
|
||||
}
|
||||
|
||||
/** 그 직원에게 보낼 기기 — 살아 있는 · 제한 없는 앱 세션의 등록 (먼저 등록한 차례) */
|
||||
async targetsOf(userEmail: string, now: Date): Promise<PushTarget[]> {
|
||||
const rows: {
|
||||
session_id: string;
|
||||
fid: string;
|
||||
seen_at: Date;
|
||||
last_pushed_at: Date | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT r."session_id", r."fid", r."seen_at", r."last_pushed_at"
|
||||
FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email"
|
||||
WHERE r."user_email" = $1 AND ${aliveAppSession('$2::timestamptz')}
|
||||
ORDER BY r."created_at", r."session_id"`,
|
||||
[userEmail, now],
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
sessionId: row.session_id,
|
||||
fid: row.fid,
|
||||
seenAt: new Date(row.seen_at),
|
||||
lastPushedAt:
|
||||
row.last_pushed_at === null ? null : new Date(row.last_pushed_at),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 보낼 기기가 하나라도 있는 직원 — `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 */
|
||||
async usersWithTargets(addressSuffix: string, now: Date): Promise<string[]> {
|
||||
const rows: { user_email: string }[] = await this.dataSource.query(
|
||||
`SELECT DISTINCT r."user_email"
|
||||
FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email"
|
||||
WHERE ${endsWithSuffix('r."user_email"', '$1::text')} AND ${aliveAppSession('$2::timestamptz')}
|
||||
ORDER BY r."user_email"`,
|
||||
[addressSuffix, now],
|
||||
);
|
||||
return rows.map((row) => row.user_email);
|
||||
}
|
||||
|
||||
/** 그 직원에게 보낼 기기가 있는가 */
|
||||
async hasTargets(userEmail: string, now: Date): Promise<boolean> {
|
||||
const rows: unknown[] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email"
|
||||
WHERE r."user_email" = $1 AND ${aliveAppSession('$2::timestamptz')}
|
||||
LIMIT 1`,
|
||||
[userEmail, now],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** 보낸 기기 — 마지막으로 보낸 때(그 알림의 N 을 센 때)를 적는다 */
|
||||
async recordPushed(sessionIds: readonly string[], at: Date): Promise<void> {
|
||||
if (sessionIds.length === 0) return;
|
||||
await this.dataSource.query(
|
||||
`UPDATE "${PUSH_REGISTRATIONS_TABLE}" SET "last_pushed_at" = $2 WHERE "session_id" = ANY($1::uuid[])`,
|
||||
[[...sessionIds], at],
|
||||
);
|
||||
}
|
||||
|
||||
/** FCM 이 받지 않는 기기 — 그 세션의 FID 가 그대로일 때만 지운다(그사이 등록이 새 FID 로 바꿨으면 둔다) → 지운 줄의 직원 주소 */
|
||||
async removeIfFid(sessionId: string, fid: string): Promise<string | null> {
|
||||
const [rows] = await this.dataSource.query<
|
||||
ChangedRows<{ user_email: string }>
|
||||
>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "session_id" = $1 AND "fid" = $2 RETURNING "user_email"`,
|
||||
[sessionId, fid],
|
||||
);
|
||||
return rows[0]?.user_email ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내기 직전 — 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는 · 제한 없는 앱 세션인가.
|
||||
* 읽은 뒤 · 엔진에 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰이 다른 세션으로 옮겨 갔으면(같은 FID 의 PUT 이 이 줄을 지웠다) false
|
||||
*/
|
||||
async isDeliverable(
|
||||
sessionId: string,
|
||||
fid: string,
|
||||
now: Date,
|
||||
): Promise<boolean> {
|
||||
const rows: unknown[] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
JOIN "${AUTH_SESSIONS_TABLE}" s ON s."id" = r."session_id" AND s."user_email" = r."user_email"
|
||||
WHERE r."session_id" = $1 AND r."fid" = $2 AND ${aliveAppSession('$3::timestamptz')}`,
|
||||
[sessionId, fid, now],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** 그 FID 를 쓰는 등록이 없는가 — 로그인이 끝난 휴대폰에 알리기 직전(그사이 그 휴대폰에서 다른 직원이 등록했으면 알리지 않는다) */
|
||||
async isFidFree(fid: string): Promise<boolean> {
|
||||
const rows: unknown[] = await this.dataSource.query(
|
||||
`SELECT 1 FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "fid" = $1 LIMIT 1`,
|
||||
[fid],
|
||||
);
|
||||
return rows.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 끝난 세션의 등록을 지운다 → 지운 줄 (`addressSuffix` 로 끝나는 주소만 — 같은 DB 의 다른 도메인 줄은 그쪽 서버가 한다).
|
||||
* 끝남 = 세션이 끊겼거나(revoked_at) · 저절로 끝났거나(미사용 · 절대 만료) · 앱 세션이 아니거나 · 세션 줄이 없다(정리 cron 이 지웠다).
|
||||
* `notify` — 제한 없던 앱 세션이 저절로 끝났거나(revoked_at 없음) 보내는 까닭(SESSION_ENDED_NOTICE)으로 끊겼다: 그 휴대폰에 한 번 알린다.
|
||||
* 까닭이 없거나 표에 없는 값이면 알리지 않는다. 지운 인스턴스만 이 줄을 받는다(DELETE … RETURNING — 두 번 알리지 않는다)
|
||||
*/
|
||||
async takeEnded(
|
||||
addressSuffix: string,
|
||||
now: Date,
|
||||
): Promise<EndedPushRegistration[]> {
|
||||
const [ended] = await this.dataSource.query<
|
||||
ChangedRows<{
|
||||
session_id: string;
|
||||
user_email: string;
|
||||
fid: string;
|
||||
notify: boolean;
|
||||
}>
|
||||
>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
USING "${AUTH_SESSIONS_TABLE}" s
|
||||
WHERE s."id" = r."session_id" AND ${endsWithSuffix('r."user_email"', '$1::text')}
|
||||
AND (s."revoked_at" IS NOT NULL OR s."expires_at" <= $2::timestamptz OR s."absolute_expires_at" <= $2::timestamptz
|
||||
OR s."client" <> '${APP_SESSION_CLIENT}' OR s."user_email" <> r."user_email")
|
||||
RETURNING r."session_id", r."user_email", r."fid",
|
||||
(s."restriction" IS NULL AND s."client" = '${APP_SESSION_CLIENT}' AND s."user_email" = r."user_email"
|
||||
AND (s."revoked_at" IS NULL
|
||||
OR (s."revoked_reason" IS NOT NULL AND s."revoked_reason" = ANY($3::text[])))) AS "notify"`,
|
||||
[addressSuffix, now, SESSION_ENDED_NOTICE_REASONS],
|
||||
);
|
||||
const [orphans] = await this.dataSource.query<
|
||||
ChangedRows<{ session_id: string; user_email: string; fid: string }>
|
||||
>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" r
|
||||
WHERE ${endsWithSuffix('r."user_email"', '$1::text')}
|
||||
AND NOT EXISTS (SELECT 1 FROM "${AUTH_SESSIONS_TABLE}" s WHERE s."id" = r."session_id")
|
||||
RETURNING r."session_id", r."user_email", r."fid"`,
|
||||
[addressSuffix],
|
||||
);
|
||||
return [
|
||||
...ended.map((row) => ({
|
||||
sessionId: row.session_id,
|
||||
userEmail: row.user_email,
|
||||
fid: row.fid,
|
||||
notify: row.notify === true,
|
||||
})),
|
||||
// 세션 줄이 없다(정리 cron 이 지웠다) — 까닭을 알 수 없어 알리지 않는다
|
||||
...orphans.map((row) => ({
|
||||
sessionId: row.session_id,
|
||||
userEmail: row.user_email,
|
||||
fid: row.fid,
|
||||
notify: false,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
private async registerOnce(
|
||||
manager: EntityManager,
|
||||
input: PushRegistrationInput,
|
||||
now: Date,
|
||||
): Promise<PushRegistered | null> {
|
||||
// 그 세션이 살아 있는 · 제한 없는 앱 세션일 때만 — 아니면 아무것도 바꾸지 않는다(다른 세션의 줄도 지우지 않는다).
|
||||
// 세션 줄은 잠그지 않는다 — 그사이 끊겨도 보낼 때 거르고 5분 정리가 지운다
|
||||
const sessions: { user_email: string }[] = await manager.query(
|
||||
`SELECT s."user_email" FROM "${AUTH_SESSIONS_TABLE}" s WHERE s."id" = $1 AND ${aliveAppSession('$2::timestamptz')}`,
|
||||
[input.sessionId, now],
|
||||
);
|
||||
const userEmail = sessions[0]?.user_email;
|
||||
if (userEmail === undefined) return null;
|
||||
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
|
||||
PUSH_FID_LOCK,
|
||||
input.fid,
|
||||
]);
|
||||
// ① 같은 휴대폰(FID)의 다른 줄 — 같은 휴대폰에서 다른 직원이 로그인했다 · 다시 로그인했다 (지운 줄의 직원에게도 바뀐 소식을 낸다)
|
||||
const [sameFid] = await manager.query<ChangedRows<{ user_email: string }>>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}" WHERE "fid" = $1 AND "session_id" <> $2 RETURNING "user_email"`,
|
||||
[input.fid, input.sessionId],
|
||||
);
|
||||
// ② 넣거나 바꾼다 — 새 줄이면 seen_at = 지금, 있던 줄의 seen_at · last_pushed_at · created_at 은 그대로
|
||||
const rows: { updated_at: Date }[] = await manager.query(
|
||||
`INSERT INTO "${PUSH_REGISTRATIONS_TABLE}"
|
||||
("session_id", "user_email", "fid", "platform", "app_version", "seen_at", "last_pushed_at", "created_at", "updated_at")
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NULL, $6, $6)
|
||||
ON CONFLICT ("session_id") DO UPDATE
|
||||
SET "fid" = EXCLUDED."fid", "platform" = EXCLUDED."platform", "app_version" = EXCLUDED."app_version", "updated_at" = EXCLUDED."updated_at"
|
||||
RETURNING "updated_at"`,
|
||||
[
|
||||
input.sessionId,
|
||||
userEmail,
|
||||
input.fid,
|
||||
input.platform,
|
||||
input.appVersion,
|
||||
now,
|
||||
],
|
||||
);
|
||||
// ③ 직원마다 상한 — 가장 오래 바꾸지 않은 줄부터 (방금 바꾼 줄은 가장 새것이다)
|
||||
const [overLimit] = await manager.query<
|
||||
ChangedRows<{ user_email: string }>
|
||||
>(
|
||||
`DELETE FROM "${PUSH_REGISTRATIONS_TABLE}"
|
||||
WHERE "session_id" IN (
|
||||
SELECT "session_id" FROM "${PUSH_REGISTRATIONS_TABLE}"
|
||||
WHERE "user_email" = $1
|
||||
ORDER BY "updated_at" DESC, "session_id" DESC
|
||||
OFFSET $2)
|
||||
RETURNING "user_email"`,
|
||||
[userEmail, MAX_PUSH_REGISTRATIONS_PER_USER],
|
||||
);
|
||||
return {
|
||||
userEmail,
|
||||
updatedAt: new Date(rows[0]?.updated_at ?? now),
|
||||
displacedUsers: [
|
||||
...new Set([...sameFid, ...overLimit].map((row) => row.user_email)),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { PushRegistrationsRepository } from './push-registrations.repository';
|
||||
import { PushRegistrationsService } from './push-registrations.service';
|
||||
|
||||
const ARA = 'ara.kim@arasystem.test';
|
||||
const SESSION = '0b6f4a1e-7c43-4c39-9a51-0d2f3c7b8e10';
|
||||
const FID = 'dQw4w9WgXcQ-ExampleFid';
|
||||
const APP_USER: AuthenticatedUser = {
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
client: 'app',
|
||||
sessionId: SESSION,
|
||||
};
|
||||
|
||||
function fakeRepository() {
|
||||
return {
|
||||
register: jest.fn(),
|
||||
refusalOf: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
markSeen: jest.fn().mockResolvedValue(true),
|
||||
targetsOf: jest.fn().mockResolvedValue([]),
|
||||
usersWithTargets: jest.fn().mockResolvedValue([]),
|
||||
hasTargets: jest.fn().mockResolvedValue(false),
|
||||
recordPushed: jest.fn().mockResolvedValue(undefined),
|
||||
removeIfFid: jest.fn(),
|
||||
isDeliverable: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
}
|
||||
|
||||
async function codeOf(promise: Promise<unknown>): Promise<string | undefined> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
return error instanceof BusinessException ? error.code : 'NOT_BUSINESS';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('PushRegistrationsService (앱 알림 등록 — 이 앱 세션의 등록만)', () => {
|
||||
let repository: ReturnType<typeof fakeRepository>;
|
||||
let service: PushRegistrationsService;
|
||||
let changed: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
repository = fakeRepository();
|
||||
service = new PushRegistrationsService(
|
||||
repository as unknown as PushRegistrationsRepository,
|
||||
);
|
||||
changed = [];
|
||||
service.onChanged((user) => changed.push(user));
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('등록 — 토큰의 세션(sid)으로 넣고 바꾼 때를 ISO 로 돌려준다, 그 직원의 등록이 바뀌었다고 알린다', async () => {
|
||||
const updatedAt = new Date('2026-11-07T05:00:00.000Z');
|
||||
repository.register.mockResolvedValue({
|
||||
userEmail: ARA,
|
||||
updatedAt,
|
||||
displacedUsers: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.register(APP_USER, {
|
||||
fid: FID,
|
||||
platform: 'android',
|
||||
appVersion: '1.0.0',
|
||||
}),
|
||||
).resolves.toEqual({ updatedAt: '2026-11-07T05:00:00.000Z' });
|
||||
expect(repository.register).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId: SESSION,
|
||||
fid: FID,
|
||||
platform: 'android',
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
expect.any(Date),
|
||||
);
|
||||
expect(changed).toEqual([ARA]);
|
||||
});
|
||||
|
||||
it('같은 휴대폰의 앞 직원 줄을 지웠으면 그 직원에게도 바뀐 소식을 낸다 — 한 직원에 한 번 (리뷰 L2)', async () => {
|
||||
const DOYUN = 'doyun.lee@arasystem.test';
|
||||
repository.register.mockResolvedValue({
|
||||
userEmail: ARA,
|
||||
updatedAt: new Date('2026-11-07T05:00:00.000Z'),
|
||||
displacedUsers: [DOYUN, ARA],
|
||||
});
|
||||
await service.register(APP_USER, {
|
||||
fid: FID,
|
||||
platform: 'android',
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
expect(changed).toEqual([ARA, DOYUN]);
|
||||
});
|
||||
|
||||
it('세션이 등록할 수 없는 세션이면 — 제한이 붙었으면 403 AUTH_010, 그 밖(끝남 · 끊김)은 401 AUTH_001 — 바뀐 것이 없다', async () => {
|
||||
repository.register.mockResolvedValue(null);
|
||||
const request = {
|
||||
fid: FID,
|
||||
platform: 'android' as const,
|
||||
appVersion: '1.0.0',
|
||||
};
|
||||
|
||||
repository.refusalOf.mockResolvedValueOnce('restricted');
|
||||
expect(await codeOf(service.register(APP_USER, request))).toBe('AUTH_010');
|
||||
repository.refusalOf.mockResolvedValueOnce('ended');
|
||||
expect(await codeOf(service.register(APP_USER, request))).toBe('AUTH_001');
|
||||
expect(changed).toEqual([]);
|
||||
});
|
||||
|
||||
it('앱 토큰이 아니면(웹 쿠키 세션 · 세션 id 없음) 403 AUTH_003 — 표에 닿지 않는다', async () => {
|
||||
const web: AuthenticatedUser = {
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
sessionId: SESSION,
|
||||
};
|
||||
const noSession: AuthenticatedUser = {
|
||||
email: ARA,
|
||||
name: '김아라',
|
||||
client: 'app',
|
||||
};
|
||||
const request = {
|
||||
fid: FID,
|
||||
platform: 'android' as const,
|
||||
appVersion: '1.0.0',
|
||||
};
|
||||
for (const user of [web, noSession]) {
|
||||
expect(await codeOf(service.register(user, request))).toBe('AUTH_003');
|
||||
expect(await codeOf(service.unregister(user))).toBe('AUTH_003');
|
||||
expect(await codeOf(service.markSeen(user))).toBe('AUTH_003');
|
||||
}
|
||||
expect(repository.register).not.toHaveBeenCalled();
|
||||
expect(repository.remove).not.toHaveBeenCalled();
|
||||
expect(repository.markSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('지우기 — 이 세션의 등록만, 지운 줄이 있을 때만 알린다 (없어도 실패가 아니다)', async () => {
|
||||
repository.remove.mockResolvedValueOnce(ARA).mockResolvedValueOnce(null);
|
||||
await service.unregister(APP_USER);
|
||||
await service.unregister(APP_USER);
|
||||
expect(repository.remove.mock.calls).toEqual([[SESSION], [SESSION]]);
|
||||
expect(changed).toEqual([ARA]);
|
||||
});
|
||||
|
||||
it('봤음 — 이 세션의 세는 시작을 지금(서버 시각)으로', async () => {
|
||||
const before = Date.now();
|
||||
await service.markSeen(APP_USER);
|
||||
const [sessionId, at] = repository.markSeen.mock.calls[0] as [string, Date];
|
||||
expect(sessionId).toBe(SESSION);
|
||||
expect(at.getTime()).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it('FCM 이 받지 않는 기기 — FID 가 그대로일 때만 지우고 그때 알린다', async () => {
|
||||
repository.removeIfFid
|
||||
.mockResolvedValueOnce(ARA)
|
||||
.mockResolvedValueOnce(null);
|
||||
await service.forgetDevice(SESSION, FID);
|
||||
await service.forgetDevice(SESSION, FID);
|
||||
expect(repository.removeIfFid).toHaveBeenCalledWith(SESSION, FID);
|
||||
expect(changed).toEqual([ARA]);
|
||||
});
|
||||
|
||||
it('듣기를 그만둘 수 있고, 듣는 쪽 하나가 던져도 다른 쪽은 받는다 (error 로그 — 주소는 가린다)', () => {
|
||||
const errors: string[] = [];
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation((message: unknown) => {
|
||||
errors.push(String(message));
|
||||
});
|
||||
const stop = service.onChanged(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const later: string[] = [];
|
||||
service.onChanged((user) => later.push(user));
|
||||
|
||||
service.notifyChanged(ARA);
|
||||
stop();
|
||||
service.notifyChanged(ARA);
|
||||
|
||||
expect(changed).toEqual([ARA, ARA]);
|
||||
expect(later).toEqual([ARA, ARA]);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]).toContain('a***@arasystem.test');
|
||||
expect(errors[0]).not.toContain(ARA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { maskEmail } from '@/shared/utils/mask';
|
||||
import type { PushPlatform } from './push.constants';
|
||||
import {
|
||||
PushRegistrationsRepository,
|
||||
type PushTarget,
|
||||
} from './push-registrations.repository';
|
||||
|
||||
/** 등록 본문 — 앱 세션(sid)은 토큰에서 */
|
||||
export interface PushRegistrationRequest {
|
||||
readonly fid: string;
|
||||
readonly platform: PushPlatform;
|
||||
readonly appVersion: string;
|
||||
}
|
||||
|
||||
/** 등록이 바뀐 직원의 소식 — 메일 쪽(new-mail-push.service.ts)이 엔진 연결을 붙잡거나 놓는다 */
|
||||
export type PushRegistrationsListener = (userEmail: string) => void;
|
||||
|
||||
/** 앱 세션의 id — 앱 토큰(Bearer · cli: app)으로 온 요청만. AppClientGuard 가 먼저 막는다(두 겹) */
|
||||
function appSessionOf(user: AuthenticatedUser): string {
|
||||
if (user.client !== 'app' || !user.sessionId) {
|
||||
throw new BusinessException('AUTH_003', {
|
||||
context: { reason: 'push-registration-requires-app-session' },
|
||||
});
|
||||
}
|
||||
return user.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 알림 등록 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 메일을 모른다. 나중의 캘린더 등도 이 등록을 쓴다.
|
||||
* - 등록 · 지우기 · "봤음"은 그 요청의 앱 세션(액세스 토큰의 sid)에만 한다 — 다른 세션의 등록을 고르는 길이 없다
|
||||
* - 등록이 바뀌면(넣음 · 지움 · FCM 이 받지 않아 지움 · 끝난 세션 정리) 소식을 낸다 — 붙잡을 직원을 메일 쪽이 다시 고른다
|
||||
* FID 는 로그 · 오류 context 에 싣지 않는다
|
||||
*/
|
||||
@Injectable()
|
||||
export class PushRegistrationsService {
|
||||
private readonly logger = new Logger(PushRegistrationsService.name);
|
||||
private readonly listeners = new Set<PushRegistrationsListener>();
|
||||
|
||||
constructor(private readonly repository: PushRegistrationsRepository) {}
|
||||
|
||||
/**
|
||||
* 이 앱 세션의 등록 — 같은 FID 의 다른 등록은 지운다(같은 휴대폰에서 다른 직원이 로그인), 직원마다 상한을 넘으면 가장 오래된 등록부터 지운다.
|
||||
* 그사이 끝난 · 끊긴 세션 401 AUTH_001, 제한이 붙은 세션 403 AUTH_010 (가드가 토큰으로 못 본 경우 — 갱신에서 제한이 붙었다)
|
||||
*/
|
||||
async register(
|
||||
user: AuthenticatedUser,
|
||||
request: PushRegistrationRequest,
|
||||
): Promise<{ updatedAt: string }> {
|
||||
const sessionId = appSessionOf(user);
|
||||
const now = new Date();
|
||||
const registered = await this.repository.register(
|
||||
{ sessionId, ...request },
|
||||
now,
|
||||
);
|
||||
if (!registered) {
|
||||
const refusal = await this.repository.refusalOf(sessionId, now);
|
||||
if (refusal === 'restricted') {
|
||||
throw new BusinessException('AUTH_010', {
|
||||
context: { reason: 'push-registration-restricted-session' },
|
||||
});
|
||||
}
|
||||
throw new BusinessException('AUTH_001', {
|
||||
context: { reason: 'push-registration-session-ended', sid: sessionId },
|
||||
});
|
||||
}
|
||||
// 같은 휴대폰의 앞 직원 · 상한을 넘어 줄이 지워진 직원도 — 그 직원의 붙잡기를 메일 쪽이 다시 고른다
|
||||
for (const user of new Set([
|
||||
registered.userEmail,
|
||||
...registered.displacedUsers,
|
||||
])) {
|
||||
this.notifyChanged(user);
|
||||
}
|
||||
return { updatedAt: registered.updatedAt.toISOString() };
|
||||
}
|
||||
|
||||
/** 이 앱 세션의 등록을 지운다 — 없어도 된다 */
|
||||
async unregister(user: AuthenticatedUser): Promise<void> {
|
||||
const removed = await this.repository.remove(appSessionOf(user));
|
||||
if (removed !== null) this.notifyChanged(removed);
|
||||
}
|
||||
|
||||
/** 이 휴대폰에서 앱을 봤다 — 다음 알림의 N 을 지금부터 센다(서버 시각). 등록이 없어도 된다 */
|
||||
async markSeen(user: AuthenticatedUser): Promise<void> {
|
||||
await this.repository.markSeen(appSessionOf(user), new Date());
|
||||
}
|
||||
|
||||
/** 그 직원에게 보낼 기기 — 살아 있는 · 제한 없는 앱 세션의 등록 */
|
||||
targetsOf(userEmail: string): Promise<PushTarget[]> {
|
||||
return this.repository.targetsOf(userEmail, new Date());
|
||||
}
|
||||
|
||||
/** 보낼 기기가 있는 직원 — `addressSuffix`(`@사내 도메인`)로 끝나는 주소만 */
|
||||
usersWithTargets(addressSuffix: string): Promise<string[]> {
|
||||
return this.repository.usersWithTargets(addressSuffix, new Date());
|
||||
}
|
||||
|
||||
/** 그 직원에게 보낼 기기가 있는가 */
|
||||
hasTargets(userEmail: string): Promise<boolean> {
|
||||
return this.repository.hasTargets(userEmail, new Date());
|
||||
}
|
||||
|
||||
/** 보낸 기기 — 그 알림의 N 을 센 때를 적는다 */
|
||||
recordPushed(sessionIds: readonly string[], countedAt: Date): Promise<void> {
|
||||
return this.repository.recordPushed(sessionIds, countedAt);
|
||||
}
|
||||
|
||||
/** 보내기 직전 — 그 세션의 등록이 아직 그 FID 이고 세션이 살아 있는 · 제한 없는 앱 세션인가 */
|
||||
isDeliverable(sessionId: string, fid: string): Promise<boolean> {
|
||||
return this.repository.isDeliverable(sessionId, fid, new Date());
|
||||
}
|
||||
|
||||
/** FCM 이 더는 받지 않는 기기 — 그 세션의 FID 가 그대로일 때만 지운다 */
|
||||
async forgetDevice(sessionId: string, fid: string): Promise<void> {
|
||||
const removed = await this.repository.removeIfFid(sessionId, fid);
|
||||
if (removed !== null) this.notifyChanged(removed);
|
||||
}
|
||||
|
||||
/** 등록이 바뀐 직원의 소식을 듣는다 → 그만 듣기 */
|
||||
onChanged(listener: PushRegistrationsListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** 등록이 바뀌었다 — 듣는 쪽에 알린다 (던지지 않는다) */
|
||||
notifyChanged(userEmail: string): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(userEmail);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[SYS_001] 앱 알림 등록이 바뀐 소식을 전하지 못했습니다: user=${maskEmail(userEmail)}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 앱 알림 보내기 포트 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 서비스는 이 포트만 안다. 지금 구현은 FCM HTTP v1 하나(configured-push-sender.ts).
|
||||
* 알림의 내용은 종류와 개수뿐이다 — 주소 · 이름 · 제목 · 메일 id · 세션 id 를 받는 칸이 없다(그래서 밖으로 보낼 수도 없다).
|
||||
*/
|
||||
export const PUSH_SENDER = Symbol('PUSH_SENDER');
|
||||
|
||||
/** 보낼 알림 — 새 메일 N통 · 그 휴대폰을 건드리지 않았는데 앱 세션이 끝남(까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE) */
|
||||
export type PushNotice =
|
||||
| { readonly kind: 'new-mail'; readonly count: number }
|
||||
| { readonly kind: 'session-ended' };
|
||||
|
||||
/**
|
||||
* 보낸 결과 — sent 보냄 · unregistered 그 기기는 더 받지 않는다(앱을 지움 · 다른 프로젝트의 기기 · 틀린 주소 — 등록을 지운다) ·
|
||||
* failed 보내지 못함(잠깐의 장애 · 우리 설정 · 서버 종료 — 등록은 그대로, 로그는 보내는 쪽이 남긴다) ·
|
||||
* skipped 보내기 직전에 그 기기가 이 알림을 받을 자리가 아니게 됐다(`stillValid` 가 false — 그 휴대폰이 다른 세션으로 옮겨 갔다 등)
|
||||
*/
|
||||
export type PushSendOutcome = 'sent' | 'unregistered' | 'failed' | 'skipped';
|
||||
|
||||
export interface PushSendOptions {
|
||||
/**
|
||||
* 보내기 직전(다시 하기마다) 아직 이 기기에 보내도 되는가 — false 면 보내지 않고 skipped, 던지면 보내지 않고 failed.
|
||||
* 읽은 뒤 · 엔진에 센 뒤 · 다시 하기를 기다리는 사이 그 휴대폰(FID)이 다른 직원의 세션으로 옮겨 가도 앞 직원의 알림이 가지 않게
|
||||
*/
|
||||
readonly stillValid?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PushSender {
|
||||
/** 보낼 수 있다 — FCM 키를 읽었다 (없으면 등록은 받되 보내지 않는다 — app-config 의 pushAvailable) */
|
||||
readonly available: boolean;
|
||||
/** 기기 하나(FID)에 보낸다 — 던지지 않는다. 다시 하기 · 시간 한도는 보내는 쪽 몫이다 */
|
||||
send(
|
||||
fid: string,
|
||||
notice: PushNotice,
|
||||
options?: PushSendOptions,
|
||||
): Promise<PushSendOutcome>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 앱 알림의 글 (계약 1절 "앱 새 메일 알림 · 앱 설정") — 휴대폰이 그대로 그리므로 원본은 여기 한 곳이다(앱 상수에는 없다).
|
||||
* 앱 README · 보드에 옮겨 적는다. 화면 문구 규칙(디자인 README 「화면 문구 — 사용자에게 말하듯」)을 따른다.
|
||||
* 글에는 메일 내용 · 주소 · 이름을 넣지 않는다 — 개수만.
|
||||
*/
|
||||
|
||||
/** 천 단위 쉼표 — 1,234 (시간대 · 기계 설정과 상관없이 같은 글) */
|
||||
const COUNT_FORMAT = new Intl.NumberFormat('en-US');
|
||||
|
||||
/** 새 메일 알림의 제목 — "새 메일 N통" (내용 칸은 없다 — 앱 이름은 안드로이드가 머리에 붙인다) */
|
||||
export function newMailTitle(count: number): string {
|
||||
return `새 메일 ${COUNT_FORMAT.format(count)}통`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 휴대폰을 직원이 건드리지 않았는데 앱 세션이 끝났을 때(저절로 끝남 · 다른 곳에서 비밀번호를 바꿈 · 앱 갱신 유예 · 토큰 재사용 감지)
|
||||
* 그 휴대폰에 한 번 보내는 알림 — 글은 보드 `App1Push` ⑧(2026-11-07 확정) 그대로다. 보내는 까닭은 push.constants.ts 의 SESSION_ENDED_NOTICE 한 곳.
|
||||
* 직원 · 운영자가 끊은 세션 · 계정을 쓸 수 없는 세션 · 제한된 세션에는 보내지 않는다
|
||||
*/
|
||||
export const SESSION_ENDED_TEXT = {
|
||||
title: '새 메일 알림이 멈췄습니다',
|
||||
body: '안전을 위해 로그아웃했습니다. 앱을 열어 다시 로그인해 주세요.',
|
||||
} as const;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 앱 알림(푸시) 상수 — 계약 1절 "앱 새 메일 알림 · 앱 설정"(앱 1단계)의 한도 · 모양을 한 곳에 모은다.
|
||||
* 글은 push-text.ts, FCM 쪽 값(주소 · 시간 · 메시지 칸)은 fcm/fcm.constants.ts 에 있다.
|
||||
*/
|
||||
|
||||
import type { SessionRevokedReason } from '@/modules/auth/auth-session.entity';
|
||||
|
||||
/** 등록 표 이름 — push-registration.entity.ts 와 같다 (날 SQL 이 쓴다) */
|
||||
export const PUSH_REGISTRATIONS_TABLE = 'push_registrations';
|
||||
|
||||
/** 세션 표 이름 — modules/auth/auth-session.entity.ts 의 @Entity 이름과 같다 (보낼 대상을 세션과 함께 고른다) */
|
||||
export const AUTH_SESSIONS_TABLE = 'auth_sessions';
|
||||
|
||||
/** 앱 세션의 클라이언트 이름 — auth_sessions.client (계약 1절 "앱 로그인") */
|
||||
export const APP_SESSION_CLIENT = 'app';
|
||||
|
||||
/**
|
||||
* 한 직원의 등록 수 상한 — 넘으면 가장 오래 바꾸지 않은(updated_at) 등록부터 지운다(같은 트랜잭션).
|
||||
* 실제로 10대를 넘게 쓸 일은 없다 — 꾸민 FID 를 잔뜩 넣어 새 메일마다 FCM 요청을 늘리지 못하게
|
||||
*/
|
||||
export const MAX_PUSH_REGISTRATIONS_PER_USER = 10;
|
||||
|
||||
/**
|
||||
* 기기 주소(FID — Firebase 설치 id) — base64url 글자(영문 · 숫자 · `-` · `_`)만, 64자까지.
|
||||
* Firebase 의 FID 는 22자다 — 여유를 둔다. FCM 등록 토큰(`:` 가 섞이고 더 길다)으로 돌아가면 이 모양과 표의 칸을 함께 바꾼다
|
||||
*/
|
||||
export const PUSH_FID_MAX_LENGTH = 64;
|
||||
export const PUSH_FID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/** 등록하는 플랫폼 — 지금은 안드로이드만 (iOS 앱이 나오면 더한다) */
|
||||
export const PUSH_PLATFORMS = ['android'] as const;
|
||||
export type PushPlatform = (typeof PUSH_PLATFORMS)[number];
|
||||
|
||||
/** 앱 판 — `숫자.숫자.숫자`(칸마다 1~4자리 · 앞자리 0 없이 — 0 하나는 된다), 앱의 PackageInfo.version */
|
||||
export const APP_VERSION_PATTERN =
|
||||
/^(?:0|[1-9][0-9]{0,3})\.(?:0|[1-9][0-9]{0,3})\.(?:0|[1-9][0-9]{0,3})$/;
|
||||
/** 앱 판 글자 수 상한 — 4 + 1 + 4 + 1 + 4 (정규식보다 먼저 본다) */
|
||||
export const APP_VERSION_MAX_LENGTH = 14;
|
||||
|
||||
/**
|
||||
* 앱 세션을 끊은 까닭마다 그 휴대폰에 세션 끝 알림(session-ended — "새 메일 알림이 멈췄습니다")을 한 번 보내는가 (계약 1절 "앱 새 메일 알림 · 앱 설정").
|
||||
* 기준 — **그 휴대폰을 직원이 건드리지 않았는데** 세션이 끝났으면 보낸다. 직원이 그 휴대폰 · 다른 기기에서 끊었거나, 운영자가 끊었거나, 계정을 쓸 수 없으면 보내지 않는다.
|
||||
* 저절로 끝난 세션(끊기지 않음 — 미사용 14일 · 절대 30일)은 까닭이 없고 늘 보낸다. 제한된 세션 · 까닭이 없거나 이 표에 없는 값(다른 판이 넣은 줄)이면 보내지 않는다.
|
||||
* 까닭(auth-session.entity.ts 의 SessionRevokedReason)을 더하거나 빼면 여기서도 정해야 컴파일된다 — 보내는 자리는 5분 정리(push-cleanup.service.ts) 하나다
|
||||
*/
|
||||
export const SESSION_ENDED_NOTICE = {
|
||||
/** 그 휴대폰에서 로그아웃 — 직원이 했다 (웹 로그아웃은 등록이 없다) */
|
||||
logout: false,
|
||||
/** 로그인된 기기 목록에서 끊기 · 다른 곳 모두 로그아웃 — 직원이 했다 */
|
||||
'ended-by-user': false,
|
||||
/** 운영 도구 — 비밀번호 재설정 · 잠금 · 이전용 비밀번호 다시 만들기 · 이전 마치기 · 퇴사 마무리 (안내는 운영자가 한다) */
|
||||
'ended-by-operator': false,
|
||||
/** 계정을 쓸 수 없다(갱신 때 엔진이 메일함을 내주지 않았다) */
|
||||
'account-unavailable': false,
|
||||
/** 2단계를 켜 끊긴 세션 — 제한된 세션(twoFactorSetup)이라 원래 보내지 않는다 */
|
||||
'two-factor-not-verified': false,
|
||||
/** 다른 곳에서 비밀번호를 바꿨다 */
|
||||
'password-changed': true,
|
||||
/** 다른 곳에서 비밀번호를 바꾸다 결과를 알 수 없게 됐다 — 바뀌었을 수 있어 끊었다 */
|
||||
'password-change-uncertain': true,
|
||||
/** 앱 갱신 유예 — 회전 응답을 잃었거나 리프레시 토큰이 두 번 쓰였다(앱에 이어 쓸 토큰이 없다) */
|
||||
'app-refresh-grace': true,
|
||||
/** 이미 쓴 리프레시 토큰이 다시 왔다(탈취 의심) */
|
||||
'refresh-token-reused': true,
|
||||
} as const satisfies Record<SessionRevokedReason, boolean>;
|
||||
|
||||
/** 세션 끝 알림을 보내는 끊은 까닭 — 위 표에서 true 인 것 (5분 정리 SQL 의 매개변수) */
|
||||
export const SESSION_ENDED_NOTICE_REASONS: readonly SessionRevokedReason[] = (
|
||||
Object.keys(SESSION_ENDED_NOTICE) as SessionRevokedReason[]
|
||||
).filter((reason) => SESSION_ENDED_NOTICE[reason]);
|
||||
|
||||
/** 끝난 세션의 등록 정리 — 5분마다(분의 30초 — 다른 5분 차례와 겹치지 않게). e2e 는 이 이름으로 멈춘다 */
|
||||
export const PUSH_CLEANUP_CRON = '30 */5 * * * *';
|
||||
export const PUSH_CLEANUP_JOB = 'push-registrations-cleanup';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { MailEngineModule } from '@/modules/mail/mail-engine.module';
|
||||
import { ConfiguredPushSender } from './configured-push-sender';
|
||||
import { FCM_TRANSPORT, FetchFcmTransport } from './fcm/fcm-transport';
|
||||
import { PushCleanupService } from './push-cleanup.service';
|
||||
import { PushRegistrationEntity } from './push-registration.entity';
|
||||
import { PushRegistrationsController } from './push-registrations.controller';
|
||||
import { PushRegistrationsRepository } from './push-registrations.repository';
|
||||
import { PushRegistrationsService } from './push-registrations.service';
|
||||
import { PUSH_SENDER } from './push-sender.port';
|
||||
|
||||
/**
|
||||
* 앱 알림(푸시) 모듈 (계약 1절 "앱 새 메일 알림 · 앱 설정" — 앱 1단계) — 등록 표 · 경로 · 끝난 세션 정리 · 보내기(PUSH_SENDER — FCM).
|
||||
* 메일을 모른다 — 새 메일을 알아채고 보내는 일은 메일 모듈(new-mail-push.service.ts)이 이 모듈을 가져다 한다(메일 → 푸시 방향만).
|
||||
* 사내 도메인(정리 범위)만 메일 엔진 설정(MailEngineModule 의 MAIL_SETTINGS)에서 읽는다 — 이 모듈은 메일 모듈을 가져오지 않는다 (순환 없음).
|
||||
* 보내는 곳을 바꿀 때는 PUSH_SENDER 의 구현만 바꾼다. 운영 도구(다른 프로세스)는 이 모듈을 가져오지 않는다 — 퇴사 마무리는 표만 지운다
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PushRegistrationEntity]),
|
||||
MailEngineModule,
|
||||
],
|
||||
controllers: [PushRegistrationsController],
|
||||
providers: [
|
||||
PushRegistrationsRepository,
|
||||
PushRegistrationsService,
|
||||
PushCleanupService,
|
||||
// FCM 의 HTTP — 앱은 전역 fetch 로 Google 의 두 주소에만 (시험은 forTesting 을 쓴다)
|
||||
{ provide: FCM_TRANSPORT, useClass: FetchFcmTransport },
|
||||
// env 의 FCM 키를 읽어 보낸다 — 없으면 보내지 않는다(available false). 서버 종료 때 진행 중인 보내기를 끊는다
|
||||
{ provide: PUSH_SENDER, useClass: ConfiguredPushSender },
|
||||
],
|
||||
exports: [PUSH_SENDER, PushRegistrationsService],
|
||||
})
|
||||
export class PushModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user