From 452d9fef1dbd73ae7cb741e736e56577d5a9cad5 Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 15:37:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EC=A4=80=EB=B9=84=20=E2=80=94=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EB=90=9C=20=EC=84=B8=EC=85=98=20=C2=B7=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=95=84=EC=88=98=20=C2=B7=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=C2=B7=20=EB=B0=9B=EC=9D=80=ED=8E=B8?= =?UTF-8?q?=EC=A7=80=ED=95=A8=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EB=AC=B8?= =?UTF-8?q?=EC=9D=98=20=EC=97=B0=EB=9D=BD=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 직원 전환 때 관리자가 계정을 다루고(입사 · 비밀번호 재설정 · 2단계 해제 · 잠금 · 퇴사 마무리 · 공용 주소) 회사가 2단계 인증을 필수로 할 수 있게 했다. - 제한된 세션(AUTH_010): 임시 비밀번호(엔진 만료) · 2단계 필수인 직원은 로그인은 되지만 새 비밀번호 · 2단계 켜기만 지난다. 푸는 요청은 그 세션만 다음 제한으로 바꾸고, 2단계를 켜면 2단계를 거친 적 없는 다른 세션은 끊는다. 만료는 게이트웨이 대리 접근 세션의 권한(다섯)으로 로그인 · 갱신 때 알아챈다. - 운영 도구 CLI(dist/cli/ops.js): 바뀔 것 → y/N → 실행 → ops_audit_log. 관리자 비밀번호는 TTY 에서만 · 입력 기록 0, 재설정은 앱 비밀번호 → 세션 → 알림 → 만료 차례(만료 뒤 대리 접근이 막힌다). - 받은편지함 알림 메일(Email/import — 링크 없음): 재설정 · 2단계 해제 · 위험 파일 판정. 끊은 세션 기억은 15초마다 DB 에서 채운다. - 2단계를 켠 잠긴 계정도 로그인은 AUTH_004 — 엔진이 잠긴 계정에도 2단계를 먼저 봐, 게이트웨이 자신의 권한으로 잠김과 설정 문제를 가른다(실사용 시험에서 찾음). 검증: lint 0 · 단위 3,138 · e2e 266 · 리뷰 두 번 · 실사용 시험(브라우저 + 운영 도구) 89/89. Co-Authored-By: Claude Opus 5.5 --- backend/src/app.module.ts | 3 + backend/src/cli/ops.ts | 40 + .../allow-restricted-session.decorator.ts | 33 + .../api-standard-response.decorator.ts | 25 + .../decorators/current-user.decorator.ts | 11 + backend/src/common/errors/error-codes.spec.ts | 6 + backend/src/common/errors/error-codes.ts | 6 + .../src/common/guards/jwt-auth.guard.spec.ts | 97 +- backend/src/common/guards/jwt-auth.guard.ts | 37 +- .../1793585725784-AddCutoverPrep.ts | 44 + .../account-security.module.ts | 9 + .../app-passwords.controller.ts | 3 +- .../account-security/dto/two-factor.dto.ts | 17 + .../login-events.controller.ts | 3 +- .../two-factor-policy.service.spec.ts | 59 + .../two-factor-policy.service.ts | 79 ++ .../account-security/two-factor.controller.ts | 66 +- .../two-factor.service.spec.ts | 157 +++ .../account-security/two-factor.service.ts | 80 +- .../src/modules/auth/auth-session.entity.ts | 25 +- .../modules/auth/auth-session.repository.ts | 67 + .../modules/auth/auth-sessions.controller.ts | 3 + .../auth/auth-sessions.service.spec.ts | 1 + backend/src/modules/auth/auth-token.types.ts | 9 + .../src/modules/auth/auth.controller.spec.ts | 81 +- backend/src/modules/auth/auth.controller.ts | 49 +- backend/src/modules/auth/auth.service.spec.ts | 429 +++++- backend/src/modules/auth/auth.service.ts | 293 +++- backend/src/modules/auth/dto/auth-user.dto.ts | 30 + .../src/modules/auth/dto/login-result.dto.ts | 18 + backend/src/modules/auth/jwt.strategy.spec.ts | 148 +- backend/src/modules/auth/jwt.strategy.ts | 37 +- .../src/modules/auth/revoked-sessions.spec.ts | 82 +- backend/src/modules/auth/revoked-sessions.ts | 91 +- .../contacts/contact-groups.controller.ts | 3 +- .../contact-restorations.controller.ts | 3 +- .../modules/contacts/contacts.controller.ts | 3 +- .../contacts/directory-entries.controller.ts | 3 +- .../file-sharing/file-sharing.repository.ts | 21 + .../file-sharing/file-sharing.service.ts | 51 + .../share-link-lookup.controller.ts | 3 +- .../file-storage/file-scan.service.spec.ts | 29 + .../modules/file-storage/file-scan.service.ts | 7 +- .../file-storage/file-storage.repository.ts | 20 + .../file-storage/file-storage.types.ts | 4 + .../file-storage/file-upload.controller.ts | 5 +- .../mail-signature.controller.ts | 3 +- .../mail-user-settings.controller.ts | 3 +- backend/src/modules/mail/draft.controller.ts | 4 +- .../mail/images/mail-image.controller.ts | 7 +- .../mail/jmap/jmap-mail.provider.spec.ts | 13 + .../modules/mail/jmap/jmap-mail.provider.ts | 8 + .../modules/mail/jmap/jmap-ops-engine.spec.ts | 646 +++++++++ .../src/modules/mail/jmap/jmap-ops-engine.ts | 784 +++++++++++ .../mail/jmap/jmap-system-mailbox.spec.ts | 207 +++ .../modules/mail/jmap/jmap-system-mailbox.ts | 206 +++ .../src/modules/mail/jmap/jmap.client.spec.ts | 237 +++- backend/src/modules/mail/jmap/jmap.client.ts | 267 +++- .../mail/mail-attachment.controller.ts | 3 +- .../src/modules/mail/mail-block.controller.ts | 3 +- .../modules/mail/mail-events.controller.ts | 6 +- .../modules/mail/mail-filter.controller.ts | 5 +- .../mail/mail-large-files.service.spec.ts | 38 + .../modules/mail/mail-large-files.service.ts | 11 +- backend/src/modules/mail/mail.module.ts | 3 + backend/src/modules/mail/mail.provider.ts | 28 + backend/src/modules/mail/mail.settings.ts | 15 + .../src/modules/mail/mailbox.controller.ts | 10 +- .../mail/message-deletion.controller.ts | 4 +- .../src/modules/mail/message.controller.ts | 4 +- .../mail/recipient-suggestion.controller.ts | 3 +- .../mail/scheduled-message.controller.ts | 3 +- .../src/modules/mail/share-link.controller.ts | 3 +- backend/src/modules/ops/account-commands.ts | 446 ++++++ backend/src/modules/ops/admin-commands.ts | 557 ++++++++ backend/src/modules/ops/lock-commands.ts | 680 +++++++++ .../src/modules/ops/ops-audit-log.entity.ts | 47 + backend/src/modules/ops/ops-audit.service.ts | 58 + backend/src/modules/ops/ops-cli.ts | 189 +++ backend/src/modules/ops/ops-commands.spec.ts | 1243 +++++++++++++++++ backend/src/modules/ops/ops-data.service.ts | 294 ++++ backend/src/modules/ops/ops-engine.port.ts | 151 ++ backend/src/modules/ops/ops-helpers.spec.ts | 223 +++ backend/src/modules/ops/ops-io.ts | 148 ++ backend/src/modules/ops/ops-prompts.ts | 128 ++ backend/src/modules/ops/ops-text.ts | 132 ++ backend/src/modules/ops/ops.module.ts | 141 ++ backend/src/modules/ops/temp-password.ts | 32 + .../modules/system-mail/system-mail.module.ts | 22 + .../system-mail/system-mail.service.spec.ts | 109 ++ .../system-mail/system-mail.service.ts | 114 ++ .../system-mail/system-mailbox.port.ts | 30 + .../system-mail/system-notices.spec.ts | 182 +++ .../src/modules/system-mail/system-notices.ts | 238 ++++ .../dto/support-contact.dto.ts | 15 + .../support-contact.controller.ts | 28 + .../system-settings/system-settings.entity.ts | 41 + .../system-settings/system-settings.module.ts | 18 + .../system-settings.service.ts | 133 ++ .../system-settings/system-settings.spec.ts | 234 ++++ .../system-settings/system-settings.types.ts | 103 ++ .../src/modules/upload/upload.controller.ts | 3 +- backend/src/shared/utils/kst-date.spec.ts | 53 + backend/src/shared/utils/kst-date.ts | 47 + backend/test/app.e2e-spec.ts | 440 +++++- flutter/lib/core/api/error_lexicon.g.dart | 1 + .../shared/constants/error-codes.generated.ts | 1 + 107 files changed, 10903 insertions(+), 192 deletions(-) create mode 100644 backend/src/cli/ops.ts create mode 100644 backend/src/common/decorators/allow-restricted-session.decorator.ts create mode 100644 backend/src/migrations/1793585725784-AddCutoverPrep.ts create mode 100644 backend/src/modules/account-security/two-factor-policy.service.spec.ts create mode 100644 backend/src/modules/account-security/two-factor-policy.service.ts create mode 100644 backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-ops-engine.ts create mode 100644 backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts create mode 100644 backend/src/modules/mail/jmap/jmap-system-mailbox.ts create mode 100644 backend/src/modules/ops/account-commands.ts create mode 100644 backend/src/modules/ops/admin-commands.ts create mode 100644 backend/src/modules/ops/lock-commands.ts create mode 100644 backend/src/modules/ops/ops-audit-log.entity.ts create mode 100644 backend/src/modules/ops/ops-audit.service.ts create mode 100644 backend/src/modules/ops/ops-cli.ts create mode 100644 backend/src/modules/ops/ops-commands.spec.ts create mode 100644 backend/src/modules/ops/ops-data.service.ts create mode 100644 backend/src/modules/ops/ops-engine.port.ts create mode 100644 backend/src/modules/ops/ops-helpers.spec.ts create mode 100644 backend/src/modules/ops/ops-io.ts create mode 100644 backend/src/modules/ops/ops-prompts.ts create mode 100644 backend/src/modules/ops/ops-text.ts create mode 100644 backend/src/modules/ops/ops.module.ts create mode 100644 backend/src/modules/ops/temp-password.ts create mode 100644 backend/src/modules/system-mail/system-mail.module.ts create mode 100644 backend/src/modules/system-mail/system-mail.service.spec.ts create mode 100644 backend/src/modules/system-mail/system-mail.service.ts create mode 100644 backend/src/modules/system-mail/system-mailbox.port.ts create mode 100644 backend/src/modules/system-mail/system-notices.spec.ts create mode 100644 backend/src/modules/system-mail/system-notices.ts create mode 100644 backend/src/modules/system-settings/dto/support-contact.dto.ts create mode 100644 backend/src/modules/system-settings/support-contact.controller.ts create mode 100644 backend/src/modules/system-settings/system-settings.entity.ts create mode 100644 backend/src/modules/system-settings/system-settings.module.ts create mode 100644 backend/src/modules/system-settings/system-settings.service.ts create mode 100644 backend/src/modules/system-settings/system-settings.spec.ts create mode 100644 backend/src/modules/system-settings/system-settings.types.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f8d008e..54c0ef6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { FileStorageModule } from '@/modules/file-storage/file-storage.module'; import { HealthModule } from '@/modules/health/health.module'; import { MailModule } from '@/modules/mail/mail.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; import { parsePositiveInt } from '@/shared/utils/env'; @@ -77,6 +78,8 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 // 메일 모듈도 가져온다(메일 → 공유 → 저장 한 방향) — 2부 파일 저장소가 같은 두 모듈을 쓴다 FileStorageModule, FileSharingModule, + // 서버 설정 (13단계) — 2단계 인증 필수 정책 · 문의 연락처(GET /api/support-contact). 운영 도구가 바꾼다 + SystemSettingsModule, UploadModule, UserModule, ], diff --git a/backend/src/cli/ops.ts b/backend/src/cli/ops.ts new file mode 100644 index 0000000..160705a --- /dev/null +++ b/backend/src/cli/ops.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; + +import { OpsCli } from '@/modules/ops/ops-cli'; +import { OPS_ALLOW_NO_TTY_ENV, TerminalIo } from '@/modules/ops/ops-io'; +import { OpsModule } from '@/modules/ops/ops.module'; + +/** + * 운영 도구 (13단계 — 계약 6절 "운영 도구") — 서버 안에서 사람이 돌리는 명령: + * sudo docker exec -it -backend node dist/cli/ops.js + * 한국어 메뉴로 묻고, 바뀔 것을 보여 주고, y/N 으로 확인받은 뒤 실행한다. 한 일은 ops_audit_log 에 남는다. + * Nest 앱 컨텍스트로 띄운다 — HTTP 서버 · cron · 실시간 엔진 연결 · 악성 검사 · 마이그레이션 없이 (modules/ops/ops.module.ts). + * 화면에는 경고 · 오류 로그만 섞인다(info 로그는 끈다) + */ +async function main(): Promise { + const app = await NestFactory.createApplicationContext(OpsModule, { + logger: ['error', 'warn'], + }); + // 표준 입력이 터미널이 아니면 도구가 비밀번호를 묻기 전에 멈춘다(-it 를 빠뜨림). 자동 시험만 OPS_ALLOW_NO_TTY=1 로 파이프를 받는다 + const io = new TerminalIo(process.stdin, process.stdout, { + allowNonInteractive: process.env[OPS_ALLOW_NO_TTY_ENV] === '1', + }); + try { + return await app.get(OpsCli).run(io); + } finally { + io.close(); + await app.close(); + } +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + process.stderr.write( + `운영 도구를 시작하지 못했습니다: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); diff --git a/backend/src/common/decorators/allow-restricted-session.decorator.ts b/backend/src/common/decorators/allow-restricted-session.decorator.ts new file mode 100644 index 0000000..0c42b7a --- /dev/null +++ b/backend/src/common/decorators/allow-restricted-session.decorator.ts @@ -0,0 +1,33 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * 제한된 세션 (계약 6절 "제한된 세션" — 13단계) — 로그인은 했지만 먼저 해야 할 일이 있는 세션. + * - passwordChange: 엔진의 비밀번호가 만료됐다(관리자가 준 임시 비밀번호) — 새 비밀번호를 정할 때까지 + * - twoFactorSetup: 회사가 2단계 인증을 필수로 정했고 시작일이 지났는데 아직 켜지 않았다 — 켤 때까지 + * 차례는 passwordChange 가 먼저다. 제한은 세션 표(auth_sessions.restriction)와 액세스 토큰(`rst`)에 있다 + */ +export const SESSION_RESTRICTIONS = [ + 'passwordChange', + 'twoFactorSetup', +] as const; +export type SessionRestriction = (typeof SESSION_RESTRICTIONS)[number]; + +export function isSessionRestriction( + value: unknown, +): value is SessionRestriction { + return ( + typeof value === 'string' && + (SESSION_RESTRICTIONS as readonly string[]).includes(value) + ); +} + +/** 메타데이터 키 — JwtAuthGuard 가 읽는다 */ +export const ALLOW_RESTRICTED_SESSION = 'allowRestrictedSession'; + +/** + * 제한된 세션으로도 부를 수 있는 핸들러 · 컨트롤러 (계약 6절) — `/api/auth/*`(me · password · sessions) · + * `/api/two-factor` · `enrollments` · `activation` 만 단다. 달지 않은 보호 라우트는 제한된 세션이면 403 AUTH_010 이다(JwtAuthGuard). + * 로그인 없이 부르는 라우트(로그인 · 갱신 · 로그아웃 · 문의 연락처)는 가드가 없어 상관없다 + */ +export const AllowRestrictedSession = (): MethodDecorator & ClassDecorator => + SetMetadata(ALLOW_RESTRICTED_SESSION, true); diff --git a/backend/src/common/decorators/api-standard-response.decorator.ts b/backend/src/common/decorators/api-standard-response.decorator.ts index 994ef86..b8f7a4c 100644 --- a/backend/src/common/decorators/api-standard-response.decorator.ts +++ b/backend/src/common/decorators/api-standard-response.decorator.ts @@ -69,3 +69,28 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) { }, }); } + +/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */ +const SESSION_REQUIRED_DESCRIPTION = + '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) — 클라이언트는 /auth/refresh 를 한 번 시도한다'; + +/** + * 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전). + * `@UseGuards(JwtAuthGuard)` 를 건 컨트롤러(클래스)에 함께 붙인다 — 가드가 두 실패를 모두 낸다. 핸들러에 같은 상태 코드를 따로 적으면 그것이 이긴다. + * 제한된 세션도 부르는 곳(`@AllowRestrictedSession()` — 인증 · 로그인된 브라우저 · 2단계 상태 · 등록 · 켜기)에는 붙이지 않는다 — AUTH_001 만 적는다 + * + * 사용 예: + * @UseGuards(JwtAuthGuard) + * @ApiSessionErrors() + */ +export function ApiSessionErrors( + description: string = SESSION_REQUIRED_DESCRIPTION, +) { + return applyDecorators( + ApiErrorResponse('AUTH_001', description), + ApiErrorResponse( + 'AUTH_010', + '제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전) — 이 API 는 부를 수 없다. 클라이언트는 알림 없이 me 로 제한을 다시 받는다 (13단계)', + ), + ); +} diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts index 7f7fb6c..bc7b60b 100644 --- a/backend/src/common/decorators/current-user.decorator.ts +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -2,6 +2,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import type { Request } from 'express'; import { BusinessException } from '@/common/errors/business.exception'; +import type { SessionRestriction } from './allow-restricted-session.decorator'; /** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */ export interface AuthenticatedUser { @@ -19,6 +20,16 @@ export interface AuthenticatedUser { * 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저") */ readonly sessionId?: string; + /** + * 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다. + * JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다 + */ + readonly restriction?: SessionRestriction | null; + /** + * 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰의 `tfd`. 아니면 null · 없음. + * 로그인 · 갱신 때 정해 토큰에 싣는다 (`me` 가 엔진 · DB 에 묻지 않고 답한다) + */ + readonly twoFactorDeadline?: string | null; } /** diff --git a/backend/src/common/errors/error-codes.spec.ts b/backend/src/common/errors/error-codes.spec.ts index b54f344..9651e67 100644 --- a/backend/src/common/errors/error-codes.spec.ts +++ b/backend/src/common/errors/error-codes.spec.ts @@ -50,6 +50,12 @@ describe('에러 코드 레지스트리', () => { expect(resolveCodeByStatus(HttpStatus.BAD_REQUEST)).toBe('VAL_001'); }); + it('제한된 세션(AUTH_010 — 13단계)은 403 이고, 403 의 상태 역매핑은 그대로 AUTH_003 이다', () => { + expect(ERROR_CODES.AUTH_010.status).toBe(HttpStatus.FORBIDDEN); + // AUTH_010 은 가드가 명시적으로만 던진다 — 프레임워크의 403 은 권한 부족(AUTH_003)으로 둔다 + expect(resolveCodeByStatus(HttpStatus.FORBIDDEN)).toBe('AUTH_003'); + }); + it('같은 이름의 메일함(MAIL_009)은 409 다', () => { expect(ERROR_CODES.MAIL_009.status).toBe(HttpStatus.CONFLICT); }); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index b7c5069..8fc346b 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -75,6 +75,12 @@ const COMMON_ERROR_CODES = { message: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', }, + // 제한된 세션(비밀번호 바꾸기 · 2단계 인증 켜기 전 — 13단계)으로 허용하지 않은 API 를 불렀다. + // 화면은 알림 없이 me 를 다시 받아 그 제한의 단계로 간다 (계약 6절 "제한된 세션"). 403 의 상태 역매핑은 그대로 AUTH_003 이다 + AUTH_010: { + status: HttpStatus.FORBIDDEN, + message: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', + }, VAL_001: { status: HttpStatus.BAD_REQUEST, message: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', diff --git a/backend/src/common/guards/jwt-auth.guard.spec.ts b/backend/src/common/guards/jwt-auth.guard.spec.ts index 048a692..ed43362 100644 --- a/backend/src/common/guards/jwt-auth.guard.spec.ts +++ b/backend/src/common/guards/jwt-auth.guard.spec.ts @@ -1,3 +1,8 @@ +import type { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthModuleOptions } from '@nestjs/passport'; + +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JwtAuthGuard } from './jwt-auth.guard'; @@ -14,7 +19,7 @@ function codeOf(run: () => unknown): string | undefined { } describe('JwtAuthGuard.handleRequest', () => { - const guard = new JwtAuthGuard(); + const guard = new JwtAuthGuard(new Reflector()); it('검증된 사용자는 그대로 통과시킨다', () => { expect(guard.handleRequest(null, user, undefined)).toBe(user); @@ -69,3 +74,93 @@ describe('JwtAuthGuard.handleRequest', () => { ); }); }); + +/** 제한된 세션 시험용 — 허용한 컨트롤러 · 허용하지 않은 컨트롤러와 핸들러 */ +@AllowRestrictedSession() +class AllowedController {} + +class MixedController {} + +const meHandler = (): void => undefined; +const statusHandler = (): void => undefined; +const deactivateHandler = (): void => undefined; +// 핸들러 하나에만 표시 — 메서드 데코레이터와 같은 메타데이터 +AllowRestrictedSession()(statusHandler); + +function contextOf( + controller: abstract new () => object, + handler: () => void, +): ExecutionContext { + return { + getHandler: () => handler, + getClass: () => controller, + } as unknown as ExecutionContext; +} + +describe('JwtAuthGuard — 제한된 세션 (계약 6절, 13단계)', () => { + const guard = new JwtAuthGuard(new Reflector()); + const restricted = { ...user, restriction: 'passwordChange' as const }; + + it('제한 없는 세션은 허용 표시가 없는 핸들러도 통과한다', () => { + const context = contextOf(MixedController, deactivateHandler); + expect(guard.handleRequest(null, user, undefined, context)).toBe(user); + expect( + guard.handleRequest( + null, + { ...user, restriction: null }, + undefined, + context, + ), + ).toEqual({ ...user, restriction: null }); + }); + + it('제한된 세션은 허용 표시가 없는 핸들러에서 403 AUTH_010 이다 — 제한은 로그 context 에만', () => { + const context = contextOf(MixedController, deactivateHandler); + expect( + codeOf(() => guard.handleRequest(null, restricted, undefined, context)), + ).toBe('AUTH_010'); + try { + guard.handleRequest(null, restricted, undefined, context); + } catch (error) { + expect((error as BusinessException).context).toEqual({ + reason: 'session-restricted', + restriction: 'passwordChange', + }); + } + }); + + it('핸들러나 컨트롤러에 @AllowRestrictedSession() 이 있으면 제한된 세션도 통과한다', () => { + expect( + guard.handleRequest( + null, + restricted, + undefined, + contextOf(MixedController, statusHandler), + ), + ).toBe(restricted); + expect( + guard.handleRequest( + null, + { ...user, restriction: 'twoFactorSetup' }, + undefined, + contextOf(AllowedController, meHandler), + ), + ).toEqual({ ...user, restriction: 'twoFactorSetup' }); + }); + + it('실행 맥락을 모르면 제한된 세션을 막는다 (닫힌 쪽)', () => { + expect(codeOf(() => guard.handleRequest(null, restricted, undefined))).toBe( + 'AUTH_010', + ); + }); +}); + +describe('JwtAuthGuard — PassportModule 설정', () => { + it('PassportModule.register 의 설정(AuthModuleOptions)을 부모 가드에 넘긴다 (리뷰 L13)', () => { + const options = Object.assign(new AuthModuleOptions(), { session: false }); + const guard = new JwtAuthGuard(new Reflector(), options); + expect((guard as unknown as { options: AuthModuleOptions }).options).toBe( + options, + ); + }); +}); diff --git a/backend/src/common/guards/jwt-auth.guard.ts b/backend/src/common/guards/jwt-auth.guard.ts index 6edc954..ccced48 100644 --- a/backend/src/common/guards/jwt-auth.guard.ts +++ b/backend/src/common/guards/jwt-auth.guard.ts @@ -1,6 +1,8 @@ -import { Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; +import { type ExecutionContext, Injectable, Optional } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard, AuthModuleOptions } from '@nestjs/passport'; +import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; @@ -12,16 +14,29 @@ export const JWT_STRATEGY = 'jwt'; * 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다. * (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다) * + * 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 — + * 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 — + * 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽). + * * 사용 예: * @UseGuards(JwtAuthGuard) * @ApiCookieAuth() */ @Injectable() export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) { + // PassportModule.register 의 설정(AuthModuleOptions)을 부모에 그대로 넘긴다 — 생성자를 두면 Nest 가 부모의 주입을 대신하지 않는다 + constructor( + private readonly reflector: Reflector, + @Optional() options?: AuthModuleOptions, + ) { + super(options); + } + handleRequest( error: unknown, user: unknown, info: unknown, + context?: ExecutionContext, ): TUser { // 전략의 validate() 가 코드와 함께 거부한 경우 그대로 전달한다 if (error instanceof BusinessException) throw error; @@ -31,8 +46,26 @@ export class JwtAuthGuard extends AuthGuard(JWT_STRATEGY) { context: { reason: describeFailure(error ?? info) }, }); } + + const restriction = (user as AuthenticatedUser).restriction ?? null; + if (restriction !== null && !this.allowsRestricted(context)) { + throw new BusinessException('AUTH_010', { + context: { reason: 'session-restricted', restriction }, + }); + } return user as TUser; } + + /** 핸들러 · 컨트롤러에 @AllowRestrictedSession() 이 있는가 — 실행 맥락이 없으면(가드 밖에서 부름) 막는다 */ + private allowsRestricted(context: ExecutionContext | undefined): boolean { + if (!context) return false; + return ( + this.reflector.getAllAndOverride( + ALLOW_RESTRICTED_SESSION, + [context.getHandler(), context.getClass()], + ) === true + ); + } } /** 토큰 원문은 남기지 않고 실패 종류만 남긴다 (예: 'No auth token', 'jwt expired') */ diff --git a/backend/src/migrations/1793585725784-AddCutoverPrep.ts b/backend/src/migrations/1793585725784-AddCutoverPrep.ts new file mode 100644 index 0000000..0b217da --- /dev/null +++ b/backend/src/migrations/1793585725784-AddCutoverPrep.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 전환 준비 표 (docs/mail/api-contract.md 6절 "전환 준비 — 13단계"). + * - auth_sessions.restriction: 제한된 세션 — passwordChange(비밀번호 바꾸기 전) · twoFactorSetup(2단계 인증 켜기 전) · null. 기존 세션은 null(제한 없음) + * - system_settings: 서버 설정 한 줄(id 1) — 2단계 인증 필수 정책(꺼짐 · 시작일 · 예외 주소)과 문의 연락처. 처음 값은 꺼짐 · 연락처 없음이다 + * - ops_audit_log: 운영 도구의 실행 기록 — 언제 · 누가 · 명령 · 대상 · 결과(비밀은 남기지 않는다). 일어난 때 인덱스 + * 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다. + * (엔티티: modules/auth/auth-session.entity.ts · modules/system-settings/system-settings.entity.ts · modules/ops/ops-audit-log.entity.ts — + * 만든 뒤 migration:generate 가 "변경 없음"인 것을 확인했다) + */ +export class AddCutoverPrep1793585725784 implements MigrationInterface { + name = 'AddCutoverPrep1793585725784'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "auth_sessions" ADD "restriction" character varying(32)`, + ); + await queryRunner.query( + `CREATE TABLE "system_settings" ("id" smallint NOT NULL, "two_factor_required" boolean NOT NULL, "two_factor_start_date" date, "two_factor_exempt" jsonb NOT NULL, "support_contact" character varying(200), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_82521f08790d248b2a80cc85d40" PRIMARY KEY ("id"))`, + ); + // 한 줄만 둔다 — 처음 값: 2단계 인증 필수 꺼짐 · 예외 없음 · 문의 연락처 없음 + await queryRunner.query( + `INSERT INTO "system_settings" ("id", "two_factor_required", "two_factor_start_date", "two_factor_exempt", "support_contact", "updated_at") VALUES (1, false, NULL, '[]', NULL, now())`, + ); + await queryRunner.query( + `CREATE TABLE "ops_audit_log" ("id" BIGSERIAL NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "operator" character varying(100) NOT NULL, "command" character varying(32) NOT NULL, "target" character varying(254), "result" character varying(16) NOT NULL, "detail" character varying(500), CONSTRAINT "PK_79f2db0d5b46ba1d43348bbc85c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ops_audit_log_occurred_at" ON "ops_audit_log" ("occurred_at") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_ops_audit_log_occurred_at"`, + ); + await queryRunner.query(`DROP TABLE "ops_audit_log"`); + await queryRunner.query(`DROP TABLE "system_settings"`); + await queryRunner.query( + `ALTER TABLE "auth_sessions" DROP COLUMN "restriction"`, + ); + } +} diff --git a/backend/src/modules/account-security/account-security.module.ts b/backend/src/modules/account-security/account-security.module.ts index 110b955..abb4a1f 100644 --- a/backend/src/modules/account-security/account-security.module.ts +++ b/backend/src/modules/account-security/account-security.module.ts @@ -2,11 +2,13 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthSessionModule } from '@/modules/auth/auth-session.module'; import { LoginAttemptModule } from '@/modules/auth/login-attempt.module'; import { ContactsModule } from '@/modules/contacts/contacts.module'; import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider'; import { MailEngineModule } from '@/modules/mail/mail-engine.module'; import { MailModule } from '@/modules/mail/mail.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; import { ACCOUNT_SECURITY_PROVIDER } from './account-security.provider'; import { ACCOUNT_SECURITY_SETTINGS, @@ -26,6 +28,7 @@ import { MailEngineEventsService } from './mail-engine-events.service'; import { TwoFactorCodeGuard } from './two-factor-code-guard'; import { TwoFactorEnrollments } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; +import { TwoFactorPolicyService } from './two-factor-policy.service'; import { TwoFactorSettingEntity } from './two-factor-setting.entity'; import { TwoFactorController } from './two-factor.controller'; import { TwoFactorService } from './two-factor.service'; @@ -64,6 +67,9 @@ function createSettings(config: ConfigService): AccountSecuritySettings { // 로그인 기록의 실패를 사내 목록으로 거른다 ContactsModule, LoginAttemptModule, + // 13단계 — 2단계 인증 필수 정책(서버 설정)과, 켜면 세션의 twoFactorSetup 제한 지우기 + SystemSettingsModule, + AuthSessionModule, ], controllers: [ TwoFactorController, @@ -85,6 +91,7 @@ function createSettings(config: ConfigService): AccountSecuritySettings { TwoFactorEnrollments, TwoFactorCodeGuard, TwoFactorLocks, + TwoFactorPolicyService, AppPasswordsService, LoginEventsService, LoginEventsCleanup, @@ -93,6 +100,8 @@ function createSettings(config: ConfigService): AccountSecuritySettings { ], exports: [ ACCOUNT_SECURITY_SETTINGS, + // 13단계 — 인증 모듈이 로그인 · 갱신에서 2단계 인증 필수 정책을 본다 + TwoFactorPolicyService, TwoFactorCodeGuard, TwoFactorLocks, AppPasswordsService, diff --git a/backend/src/modules/account-security/app-passwords.controller.ts b/backend/src/modules/account-security/app-passwords.controller.ts index e6043a9..3a2da76 100644 --- a/backend/src/modules/account-security/app-passwords.controller.ts +++ b/backend/src/modules/account-security/app-passwords.controller.ts @@ -18,6 +18,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -56,7 +57,7 @@ function toDto(view: AppPasswordView): AppPasswordDto { @ApiTags('Account security') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('app-passwords') export class AppPasswordsController { constructor(private readonly appPasswords: AppPasswordsService) {} diff --git a/backend/src/modules/account-security/dto/two-factor.dto.ts b/backend/src/modules/account-security/dto/two-factor.dto.ts index a5ff824..769b229 100644 --- a/backend/src/modules/account-security/dto/two-factor.dto.ts +++ b/backend/src/modules/account-security/dto/two-factor.dto.ts @@ -24,6 +24,23 @@ export class TwoFactorStatusDto { example: '2026-10-31T00:12:00.000Z', }) enabledAt!: string | null; + + @ApiProperty({ + description: + '이 직원에게 2단계 인증이 필수인가 (13단계 — 회사 정책, 시작일 전에도 true). 필수인 직원은 끄지 못한다(끄기는 400 VAL_001)', + example: false, + }) + isRequired!: boolean; + + @ApiProperty({ + description: + '필수의 유예 중이고 아직 켜지 않았으면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 켰거나 시작했거나 필수가 아니면 null(로그인 · me 의 twoFactorDeadline 과 같은 규칙). 화면은 그 1ms 앞 날짜(시작일 전날)까지로 안내한다', + type: String, + format: 'date-time', + nullable: true, + example: '2026-11-30T15:00:00.000Z', + }) + deadline!: string | null; } /** POST /api/two-factor/enrollments → 201 (Cache-Control: private, no-store) */ diff --git a/backend/src/modules/account-security/login-events.controller.ts b/backend/src/modules/account-security/login-events.controller.ts index fbfa014..0c8cb99 100644 --- a/backend/src/modules/account-security/login-events.controller.ts +++ b/backend/src/modules/account-security/login-events.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -41,7 +42,7 @@ function toDto(event: LoginEventEntity): LoginEventDto { @ApiTags('Account security') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') +@ApiSessionErrors('로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('login-events') export class LoginEventsController { constructor(private readonly loginEvents: LoginEventsService) {} diff --git a/backend/src/modules/account-security/two-factor-policy.service.spec.ts b/backend/src/modules/account-security/two-factor-policy.service.spec.ts new file mode 100644 index 0000000..2ab540a --- /dev/null +++ b/backend/src/modules/account-security/two-factor-policy.service.spec.ts @@ -0,0 +1,59 @@ +import type { Repository } from 'typeorm'; + +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { DEFAULT_SYSTEM_SETTINGS } from '@/modules/system-settings/system-settings.types'; +import type { AccountSecurityProvider } from './account-security.provider'; +import { TwoFactorPolicyService } from './two-factor-policy.service'; +import type { TwoFactorSettingEntity } from './two-factor-setting.entity'; + +const ARA = 'ara.kim@arasystem.test'; + +describe('TwoFactorPolicyService — 2단계 인증 켜짐 판정 (13단계)', () => { + let records: { existsBy: jest.Mock; delete: jest.Mock }; + let provider: { isTwoFactorEnabled: jest.Mock }; + let service: TwoFactorPolicyService; + + beforeEach(() => { + records = { + existsBy: jest.fn().mockResolvedValue(true), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + provider = { isTwoFactorEnabled: jest.fn().mockResolvedValue(false) }; + service = new TwoFactorPolicyService( + { + get: jest.fn().mockResolvedValue(DEFAULT_SYSTEM_SETTINGS), + } as unknown as SystemSettingsService, + records as unknown as Repository, + provider as unknown as AccountSecurityProvider, + ); + }); + + it('isEnabledInEngine — 우리 기록이 아니라 엔진에 묻는다: 기록이 남았어도 엔진이 꺼짐이면 false 이고 기록을 지운다 (재리뷰 N1)', async () => { + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false); + expect(records.existsBy).not.toHaveBeenCalled(); + expect(provider.isTwoFactorEnabled).toHaveBeenCalledWith(ARA); + expect(records.delete).toHaveBeenCalledWith({ userEmail: ARA }); + }); + + it('isEnabledInEngine — 엔진이 켜짐이면 true, 기록은 건드리지 않는다', async () => { + provider.isTwoFactorEnabled.mockResolvedValueOnce(true); + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(true); + expect(records.delete).not.toHaveBeenCalled(); + }); + + it('isEnabledInEngine — 기록 정리가 실패해도 판정은 그대로, 엔진 장애는 던진다', async () => { + records.delete.mockRejectedValueOnce(new Error('db down')); + await expect(service.isEnabledInEngine(ARA)).resolves.toBe(false); + + provider.isTwoFactorEnabled.mockRejectedValueOnce(new Error('engine down')); + await expect(service.isEnabledInEngine(ARA)).rejects.toThrow('engine down'); + }); + + it('isEnabled — 기록이 있으면 켜짐(엔진에 묻지 않는다), 없으면 엔진', async () => { + await expect(service.isEnabled(ARA)).resolves.toBe(true); + expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); + records.existsBy.mockResolvedValueOnce(false); + provider.isTwoFactorEnabled.mockResolvedValueOnce(true); + await expect(service.isEnabled(ARA)).resolves.toBe(true); + }); +}); diff --git a/backend/src/modules/account-security/two-factor-policy.service.ts b/backend/src/modules/account-security/two-factor-policy.service.ts new file mode 100644 index 0000000..65a4e02 --- /dev/null +++ b/backend/src/modules/account-security/two-factor-policy.service.ts @@ -0,0 +1,79 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + sessionTwoFactorState, + twoFactorRequirementFor, + type SessionTwoFactorState, + type TwoFactorRequirement, +} from '@/modules/system-settings/system-settings.types'; +import { + ACCOUNT_SECURITY_PROVIDER, + type AccountSecurityProvider, +} from './account-security.provider'; +import { TwoFactorSettingEntity } from './two-factor-setting.entity'; + +/** + * 2단계 인증 필수 정책의 판정 (13단계 — 계약 6절 "2단계 인증 필수") — 정책은 서버 설정(system_settings), 켜짐은 엔진이 기준이다. + * - 로그인: 엔진의 답(402 면 켜짐)을 그대로 넘겨받는다 (knownEnabled) + * - 갱신: 우리 기록(two_factor_settings — 웹메일에서 켠 때)이 있으면 켜짐, 없으면 엔진에 묻는다 + * (엔진 관리 화면에서 켠 직원은 기록이 없다 — 기록만 보면 켠 직원을 막게 된다) + * 정책이 꺼졌거나 예외인 직원은 엔진에 묻지 않는다 + */ +@Injectable() +export class TwoFactorPolicyService { + constructor( + private readonly settings: SystemSettingsService, + @InjectRepository(TwoFactorSettingEntity) + private readonly records: Repository, + @Inject(ACCOUNT_SECURITY_PROVIDER) + private readonly provider: AccountSecurityProvider, + ) {} + + /** 이 직원에게 정책이 걸리는가 (GET /api/two-factor 의 isRequired · deadline, 끄기 막기) */ + async requirementFor( + email: string, + now: Date = new Date(), + ): Promise { + const { twoFactor } = await this.settings.get(); + return twoFactorRequirementFor(twoFactor, email, now); + } + + /** + * 세션에 붙일 2단계 인증 상태 — 제한(twoFactorSetup) 또는 유예 중의 시작 시각. + * `knownEnabled` 를 모르면(갱신) 우리 기록 → 엔진 차례로 본다 — 엔진 장애는 그대로 던진다(갱신은 세션을 남기고 503) + */ + async sessionState( + email: string, + knownEnabled?: boolean, + now: Date = new Date(), + ): Promise { + const requirement = await this.requirementFor(email, now); + if (!requirement.isRequired) { + return { restriction: null, deadline: null }; + } + const enabled = knownEnabled ?? (await this.isEnabled(email)); + return sessionTwoFactorState(requirement, enabled); + } + + /** + * 엔진에서 2단계 인증이 켜져 있는가 — 우리 기록을 보지 않는다. 엔진이 꺼짐인데 기록이 남았으면(엔진 관리 화면 · 운영 도구에서 껐다) 기록을 지운다. + * 갱신이 2단계를 거치지 않은 세션을 끊을지 정할 때 쓴다 — 오래된 기록 때문에 제한된 세션이 갱신마다 끊기지 않게(재리뷰 N1). 엔진 장애는 그대로 던진다 + */ + async isEnabledInEngine(email: string): Promise { + const enabled = await this.provider.isTwoFactorEnabled(email); + if (!enabled) { + // 기록 정리는 실패해도 판정은 그대로다 — 다음 상태 읽기(GET /api/two-factor)도 맞춘다 + await this.records.delete({ userEmail: email }).catch(() => undefined); + } + return enabled; + } + + /** 2단계 인증이 켜져 있는가 — 우리 기록(웹메일에서 켠 때) → 엔진. 엔진 장애는 그대로 던진다 */ + async isEnabled(email: string): Promise { + if (await this.records.existsBy({ userEmail: email })) return true; + return this.provider.isTwoFactorEnabled(email); + } +} diff --git a/backend/src/modules/account-security/two-factor.controller.ts b/backend/src/modules/account-security/two-factor.controller.ts index aaac70d..231c8d6 100644 --- a/backend/src/modules/account-security/two-factor.controller.ts +++ b/backend/src/modules/account-security/two-factor.controller.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/common'; import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -26,12 +27,17 @@ import { TwoFactorEnrollmentDto, TwoFactorStatusDto, } from './dto/two-factor.dto'; -import { TwoFactorService, type TwoFactorStatus } from './two-factor.service'; +import { + TwoFactorService, + type TwoFactorStatusView, +} from './two-factor.service'; -function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto { +function toStatusDto(status: TwoFactorStatusView): TwoFactorStatusDto { return { isEnabled: status.isEnabled, enabledAt: status.enabledAt?.toISOString() ?? null, + isRequired: status.isRequired, + deadline: status.deadline?.toISOString() ?? null, }; } @@ -44,25 +50,34 @@ function toStatusDto(status: TwoFactorStatus): TwoFactorStatusDto { 'AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) · 웹메일로 다루지 않는 계정(메일 엔진 관리자 등)', ) +// 13단계 — 상태 · 등록 · 켜기는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다(@AllowRestrictedSession). 끄기는 403 AUTH_010 @Controller('two-factor') export class TwoFactorController { constructor(private readonly twoFactor: TwoFactorService) {} @Get() + @AllowRestrictedSession() @ApiOperation({ summary: '2단계 인증 상태', description: - '엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다.', + '엔진이 기준이다 — 켜짐은 엔진의 비밀번호 자격 증명에 OTP 가 있는지로 본다(대리 접근). 서버는 웹메일에서 켠 때만 둔다 — 엔진에서 꺼졌으면 지운다. ' + + '13단계: isRequired · deadline — 회사의 2단계 인증 필수 정책. 제한된 세션도 부른다.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '지금 상태' }) @ApiErrorResponse('MAIL_001', '메일 엔진 장애') async status( @CurrentUser() user: AuthenticatedUser, ): Promise { - return toStatusDto(await this.twoFactor.getStatus(user.email)); + return toStatusDto( + await this.twoFactor.withPolicy( + user.email, + await this.twoFactor.getStatus(user.email), + ), + ); } @Post('enrollments') + @AllowRestrictedSession() @NoStore() @ApiOperation({ summary: '2단계 인증 등록 시작 — 비밀과 otpauth 주소 (꺼져 있을 때만)', @@ -90,13 +105,15 @@ export class TwoFactorController { } @Post('activation') + @AllowRestrictedSession() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '2단계 인증 켜기 — 첫 코드와 지금 비밀번호', description: '코드를 등록의 비밀로 먼저 확인한다(앞뒤 30초) — 엔진은 등록 때 코드를 보지 않는다. 둘 다 틀렸으면 코드부터 알린다(AUTH_006). ' + '그다음 지금 비밀번호를 확인하고(틀리면 AUTH_005 — 로그인 실패 한도로 센다) 엔진에 넣는다. 켠 코드는 2분 동안 다시 받지 않는다. ' + - '다른 곳의 웹메일 로그인은 끊지 않는다.', + '다른 곳의 웹메일 로그인은 끊지 않는다. 13단계: 켜면 이 요청의 세션의 twoFactorSetup 제한을 지우고(클라이언트는 곧바로 /auth/refresh 로 새 토큰을 받는다), ' + + '그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것(2단계를 거친 적이 없는 세션)은 끊는다.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '켰다' }) // 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다) @@ -121,17 +138,16 @@ export class TwoFactorController { @Body() dto: TwoFactorActivationDto, @Ip() ip: string, ): Promise { - return toStatusDto( - await this.twoFactor.activate( - user.email, - { - enrollmentId: dto.enrollmentId, - otpCode: dto.otpCode, - currentPassword: dto.currentPassword, - }, - { ip }, - ), + const status = await this.twoFactor.activate( + user.email, + { + enrollmentId: dto.enrollmentId, + otpCode: dto.otpCode, + currentPassword: dto.currentPassword, + }, + { ip, sessionId: user.sessionId ?? null }, ); + return toStatusDto(await this.twoFactor.withPolicy(user.email, status)); } @Post('deactivation') @@ -140,14 +156,19 @@ export class TwoFactorController { summary: '2단계 인증 끄기 — 지금 비밀번호와 지금 코드', description: '비밀번호를 먼저 확인한다(틀리면 AUTH_005 — 로그인 실패 한도로 센다). 이미 꺼져 있으면 200(같은 결과). ' + - '코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다.', + '코드는 엔진이 확인한다(틀리거나 2분 안에 다시 쓴 코드면 AUTH_006 — 코드 실패로 센다). 만들어 둔 앱 비밀번호는 그대로 남는다. ' + + '13단계: 회사 정책으로 필수인 직원은 400 VAL_001(비밀번호를 묻기 전에). 제한된 세션은 403 AUTH_010.', }) @ApiStandardResponse(TwoFactorStatusDto, { description: '껐다 (또는 이미 꺼져 있었다)', }) @ApiErrorResponse( 'AUTH_006', - 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 (모두 400)', + 'AUTH_006 코드 틀림 · 2분 안에 다시 쓴 코드 / AUTH_005 지금 비밀번호 틀림 · 앱 비밀번호 / VAL_001 형식 오류 · 회사 정책으로 필수인 직원 (모두 400)', + ) + @ApiErrorResponse( + 'AUTH_010', + '제한된 세션 — 먼저 비밀번호를 바꾸거나 2단계 인증을 켠다 (13단계)', ) @ApiErrorResponse('REQ_001', '코드 실패 한도 · 비밀번호 실패 한도 · 처리 중') @ApiErrorResponse( @@ -159,12 +180,11 @@ export class TwoFactorController { @Body() dto: TwoFactorDeactivationDto, @Ip() ip: string, ): Promise { - return toStatusDto( - await this.twoFactor.deactivate( - user.email, - { currentPassword: dto.currentPassword, otpCode: dto.otpCode }, - { ip }, - ), + const status = await this.twoFactor.deactivate( + user.email, + { currentPassword: dto.currentPassword, otpCode: dto.otpCode }, + { ip }, ); + return toStatusDto(await this.twoFactor.withPolicy(user.email, status)); } } diff --git a/backend/src/modules/account-security/two-factor.service.spec.ts b/backend/src/modules/account-security/two-factor.service.spec.ts index a64a578..fba2d95 100644 --- a/backend/src/modules/account-security/two-factor.service.spec.ts +++ b/backend/src/modules/account-security/two-factor.service.spec.ts @@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'; import type { Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; +import type { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; import { LOGIN_ADDRESS_FAILURE_LIMIT, TWO_FACTOR_CODE_ADDRESS_LIMIT, @@ -19,6 +20,7 @@ import type { import { TwoFactorCodeGuard } from './two-factor-code-guard'; import { TwoFactorEnrollments } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; +import type { TwoFactorPolicyService } from './two-factor-policy.service'; import type { TwoFactorSettingEntity } from './two-factor-setting.entity'; import { TwoFactorService } from './two-factor.service'; @@ -122,9 +124,24 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( let service: TwoFactorService; let logs: string[]; let errors: string[]; + /** 2단계 인증 필수 정책 대역 (13단계) — 처음은 필수 아님 */ + let policy: { requirementFor: jest.Mock }; + /** 세션 표 대역 — 켜면 twoFactorSetup 제한을 지운다 (13단계) */ + let sessions: { clearRestriction: jest.Mock; revokeRestrictedOf: jest.Mock }; beforeEach(() => { engineEnabled = false; + policy = { + requirementFor: jest.fn().mockResolvedValue({ + isRequired: false, + startsAt: null, + isStarted: false, + }), + }; + sessions = { + clearRestriction: jest.fn().mockResolvedValue(true), + revokeRestrictedOf: jest.fn().mockResolvedValue(0), + }; provider = { isTwoFactorEnabled: jest.fn, [string]>(() => Promise.resolve(engineEnabled), @@ -162,6 +179,8 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( codes, locks, attempts, + policy as unknown as TwoFactorPolicyService, + sessions as unknown as AuthSessionRepository, ); logs = []; errors = []; @@ -777,4 +796,142 @@ describe('TwoFactorService (2단계 인증 켜기 · 끄기 — 계약 5절)', ( ).toBe('AUTH_001'); expect(provider.isTwoFactorEnabled).not.toHaveBeenCalled(); }); + + describe('2단계 인증 필수 정책 (13단계 — 계약 6절)', () => { + it('켜면 요청 세션의 twoFactorSetup 제한만 지우고, twoFactorSetup 이 붙은 다른 세션은 끊는다 (리뷰 M1)', async () => { + sessions.revokeRestrictedOf.mockResolvedValueOnce(2); + const { enrollment, code } = await enroll(); + await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP, sessionId: 'sid-mine' }, + ); + expect(sessions.clearRestriction).toHaveBeenCalledWith( + 'sid-mine', + 'twoFactorSetup', + ); + expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith( + ARA, + 'twoFactorSetup', + 'two-factor-not-verified', + expect.any(Date), + 'sid-mine', + ); + // 그 직원의 모든 세션 제한을 한꺼번에 지우지 않는다 — 비밀번호만 아는 다른 사람의 세션이 2단계 없이 열리지 않게 + expect(sessions.clearRestriction).toHaveBeenCalledTimes(1); + }); + + it('요청 세션을 모르면(sid 없는 옛 토큰) 지우지 않고, twoFactorSetup 이 붙은 세션은 모두 끊는다', async () => { + const { enrollment, code } = await enroll(); + await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP }, + ); + expect(sessions.clearRestriction).not.toHaveBeenCalled(); + expect(sessions.revokeRestrictedOf).toHaveBeenCalledWith( + ARA, + 'twoFactorSetup', + 'two-factor-not-verified', + expect.any(Date), + null, + ); + }); + + it('제한 정리가 실패해도 켜기는 성공이다 — error 로그만(갱신이 다시 본다)', async () => { + sessions.clearRestriction.mockRejectedValueOnce(new Error('db down')); + const { enrollment, code } = await enroll(); + const status = await service.activate( + ARA, + { + enrollmentId: enrollment.enrollmentId, + otpCode: code(), + currentPassword: PASSWORD, + }, + { ip: IP, sessionId: 'sid-mine' }, + ); + expect(status.isEnabled).toBe(true); + expect(errors.join('\n')).toContain('세션의 제한을 정리하지 못했습니다'); + }); + + it('필수인 직원의 끄기는 400 VAL_001 — 비밀번호를 묻기 전에 막고 엔진에 보내지 않는다', async () => { + engineEnabled = true; + policy.requirementFor.mockResolvedValue({ + isRequired: true, + startsAt: new Date('2026-11-30T15:00:00.000Z'), + isStarted: false, + }); + expect( + ( + await failureOf( + service.deactivate( + ARA, + { currentPassword: PASSWORD, otpCode: '123456' }, + { ip: IP }, + ), + ) + )?.code, + ).toBe('VAL_001'); + expect(mail.verifyCredentials).not.toHaveBeenCalled(); + expect(provider.disableTwoFactor).not.toHaveBeenCalled(); + }); + + it('응답에 정책을 더한다 — 유예 중이고 꺼져 있으면 시작 시각, 켰거나 시작했거나 필수가 아니면 null', async () => { + const startsAt = new Date('2026-11-30T15:00:00.000Z'); + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: false, + }); + await expect( + service.withPolicy(ARA, { isEnabled: false, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: true, + deadline: startsAt, + }); + + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: true, + }); + await expect( + service.withPolicy(ARA, { isEnabled: true, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + isRequired: true, + deadline: null, + }); + + await expect( + service.withPolicy(ARA, { isEnabled: false, enabledAt: null }), + ).resolves.toMatchObject({ isRequired: false, deadline: null }); + + // 유예 중이어도 이미 켰으면 null — 켠 직원에게는 켤 기한이 없다 + policy.requirementFor.mockResolvedValueOnce({ + isRequired: true, + startsAt, + isStarted: false, + }); + await expect( + service.withPolicy(ARA, { isEnabled: true, enabledAt: null }), + ).resolves.toEqual({ + isEnabled: true, + enabledAt: null, + isRequired: true, + deadline: null, + }); + }); + }); }); diff --git a/backend/src/modules/account-security/two-factor.service.ts b/backend/src/modules/account-security/two-factor.service.ts index 4e970cb..5fdc3bb 100644 --- a/backend/src/modules/account-security/two-factor.service.ts +++ b/backend/src/modules/account-security/two-factor.service.ts @@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from 'crypto'; import type { Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; +import { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; import { isEmployeeAddress, normalizeEmail, @@ -32,6 +33,7 @@ import { enrollmentExpiry, } from './two-factor-enrollments'; import { TwoFactorLocks } from './two-factor-locks'; +import { TwoFactorPolicyService } from './two-factor-policy.service'; import { TwoFactorSettingEntity } from './two-factor-setting.entity'; /** GET /api/two-factor 의 모양 — 켜기 · 끄기의 응답도 같다 */ @@ -41,6 +43,14 @@ export interface TwoFactorStatus { readonly enabledAt: Date | null; } +/** 응답의 모양 — 상태 + 2단계 인증 필수 정책 (13단계 — 계약 6절 "2단계 인증 필수") */ +export interface TwoFactorStatusView extends TwoFactorStatus { + /** 이 직원에게 필수인가 — 정책이 켜져 있고 예외가 아니다(시작일 전에도). 필수인 직원은 끄지 못한다 */ + readonly isRequired: boolean; + /** 필수의 유예 중이면 시작 시각(시작일 0시 KST) — 아니면 null */ + readonly deadline: Date | null; +} + /** 등록 — 비밀 · otpauth 주소는 이 응답에만 (no-store) */ export interface TwoFactorEnrollmentView { readonly enrollmentId: string; @@ -53,6 +63,8 @@ export interface TwoFactorEnrollmentView { /** 요청한 클라이언트 — 실패 한도의 IP (모르면 빈 값 · undefined — 'unknown' 한 묶음으로 센다) */ export interface SecurityClient { readonly ip: string | undefined; + /** 요청의 세션(액세스 토큰의 sid) — 2단계를 켜면 이 세션의 제한만 지운다(13단계). 모르면 null */ + readonly sessionId?: string | null; } function errorMessage(error: unknown): string { @@ -91,8 +103,30 @@ export class TwoFactorService { private readonly codes: TwoFactorCodeGuard, private readonly locks: TwoFactorLocks, private readonly attempts: LoginAttemptService, + /** 13단계 — 2단계 인증 필수 정책(끄기 막기 · 응답의 isRequired · deadline), 켜면 세션의 twoFactorSetup 제한을 지운다 */ + private readonly policy: TwoFactorPolicyService, + private readonly sessions: AuthSessionRepository, ) {} + /** + * 상태에 정책을 더한다 — GET · 켜기 · 끄기의 응답 (계약 6절: isRequired · deadline). + * deadline 은 로그인 · me 의 twoFactorDeadline 과 같은 규칙이다 — 유예 중이고 아직 켜지 않았을 때만 시작 시각 + */ + async withPolicy( + user: string, + status: TwoFactorStatus, + ): Promise { + const requirement = await this.policy.requirementFor(normalizeEmail(user)); + return { + ...status, + isRequired: requirement.isRequired, + deadline: + requirement.isRequired && !requirement.isStarted && !status.isEnabled + ? requirement.startsAt + : null, + }; + } + /** GET /api/two-factor — 엔진에서 꺼졌으면 켠 때 기록을 지운다 */ async getStatus(user: string): Promise { const address = this.requireEmployee(user); @@ -192,6 +226,7 @@ export class TwoFactorService { } const enabledAt = await this.recordEnabledAt(address); + await this.settleSetupRestriction(address, client.sessionId ?? null); this.logger.log(`2단계 인증을 켰습니다: ${maskEmail(address)}`); return { isEnabled: true, enabledAt }; }); @@ -246,7 +281,42 @@ export class TwoFactorService { } } - /** POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과) */ + /** + * 2단계를 켰다 — **요청의 세션**(액세스 토큰의 sid)의 twoFactorSetup 제한만 지우고, 그 직원의 다른 세션 가운데 twoFactorSetup 이 붙은 것은 끊는다 + * (2단계를 거친 적이 없는 세션 — 비밀번호만 아는 다른 사람의 세션일 수 있다, 계약 6절 "제한된 세션"). + * 실패해도 켜기는 성공이다(error 로그) — 갱신이 다시 본다: 세션 표가 twoFactorSetup 인데 계정이 켜짐이면 그 세션을 끊는다(이 세션이면 다시 로그인) + */ + private async settleSetupRestriction( + address: string, + sessionId: string | null, + ): Promise { + try { + if (sessionId !== null) { + await this.sessions.clearRestriction(sessionId, 'twoFactorSetup'); + } + const ended = await this.sessions.revokeRestrictedOf( + address, + 'twoFactorSetup', + 'two-factor-not-verified', + new Date(), + sessionId, + ); + if (ended > 0) { + this.logger.warn( + `[AUTH_001]${traceTag()} 2단계 인증을 켜 2단계를 거치지 않은 다른 세션 ${ended}개를 끊었습니다: user=${maskEmail(address)}`, + ); + } + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 2단계 인증을 켰지만 세션의 제한을 정리하지 못했습니다 — 다음 갱신이 다시 봅니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + + /** + * POST /api/two-factor/deactivation — 이미 꺼져 있으면 200 (같은 결과). + * 2단계 인증이 필수인 직원(정책 — 시작일 전 포함)은 400 VAL_001 — 비밀번호를 묻기 전에 막는다 (계약 6절) + */ async deactivate( user: string, input: { readonly currentPassword: string; readonly otpCode: string }, @@ -254,6 +324,14 @@ export class TwoFactorService { ): Promise { const address = this.requireEmployee(user); const ip = client.ip || 'unknown'; + if ((await this.policy.requirementFor(address)).isRequired) { + throw new BusinessException('VAL_001', { + context: { + reason: 'two-factor-required-by-policy', + email: maskEmail(address), + }, + }); + } return this.locks.run(address, async () => { if (isAppPasswordShape(input.currentPassword)) { throw this.currentPasswordRejected(address, 'app-password-shape'); diff --git a/backend/src/modules/auth/auth-session.entity.ts b/backend/src/modules/auth/auth-session.entity.ts index 99e7b4b..f23870d 100644 --- a/backend/src/modules/auth/auth-session.entity.ts +++ b/backend/src/modules/auth/auth-session.entity.ts @@ -1,5 +1,7 @@ import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; + /** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */ const EMAIL_COLUMN_LENGTH = 254; /** SHA-256 16진 문자열 길이 */ @@ -10,6 +12,8 @@ const REVOKED_REASON_LENGTH = 32; const IP_COLUMN_LENGTH = 64; /** User-Agent 원문 길이 (계약 5절 — 512자까지) — shared/utils/user-agent.ts 의 USER_AGENT_MAX_LENGTH 와 같다 */ const USER_AGENT_COLUMN_LENGTH = 512; +/** 제한 이름 길이 — 'passwordChange' · 'twoFactorSetup' (여유를 둔다) */ +const RESTRICTION_COLUMN_LENGTH = 32; /** 세션을 끝낸 사유 — 로그·문의 대응용 */ export type SessionRevokedReason = @@ -21,7 +25,14 @@ export type SessionRevokedReason = /** 비밀번호 바꾸기의 결과를 알 수 없다(엔진이 바꿨을 수 있다) — 바꾼 세션 밖의 그 직원 세션 */ | 'password-change-uncertain' /** 직원이 로그인된 브라우저 목록에서 끊었다 — 하나 · 다른 곳 모두 (계약 5절 "로그인된 브라우저") */ - | 'ended-by-user'; + | 'ended-by-user' + /** 운영 도구가 끊었다 — 비밀번호 재설정 · 잠금 (계약 6절 "운영 도구") */ + | 'ended-by-operator' + /** + * 2단계 인증을 거친 적이 없는 세션(twoFactorSetup 제한)인데 그 직원이 2단계를 켰다 — 다른 세션에서 켰을 때 · 갱신에서 계정이 이미 켜짐일 때 + * (비밀번호만 아는 다른 사람의 세션일 수 있다 — 계약 6절 "제한된 세션") + */ + | 'two-factor-not-verified'; /** * 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다. @@ -30,6 +41,7 @@ export type SessionRevokedReason = * - 액세스 토큰은 이 표를 보지 않는다 (서버 조회 없이 검증). 12단계부터 액세스 토큰에 세션 id(sid)를 싣고, * 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다) * - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null + * - 제한(restriction — 13단계)은 로그인 · 갱신 때 정하고 액세스 토큰에도 싣는다. 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 지운다 (계약 6절 "제한된 세션") * - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts) */ @Entity({ name: 'auth_sessions' }) @@ -97,4 +109,15 @@ export class AuthSessionEntity { nullable: true, }) userAgent!: string | null; + + /** + * 제한된 세션 (13단계 — 계약 6절) — passwordChange · twoFactorSetup · null(제한 없음). + * 로그인 때 엔진의 답(비밀번호 만료 · 2단계 켜짐)과 정책으로 정하고, 갱신 때 다시 정한다. 푸는 요청이 성공하면 지운다 + */ + @Column({ + type: 'varchar', + length: RESTRICTION_COLUMN_LENGTH, + nullable: true, + }) + restriction!: SessionRestriction | null; } diff --git a/backend/src/modules/auth/auth-session.repository.ts b/backend/src/modules/auth/auth-session.repository.ts index 3ea26d0..615ae96 100644 --- a/backend/src/modules/auth/auth-session.repository.ts +++ b/backend/src/modules/auth/auth-session.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, MoreThan, type Repository } from 'typeorm'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import { AuthSessionEntity, type SessionRevokedReason, @@ -18,6 +19,8 @@ export interface RotateSessionCommand { /** 이 갱신의 클라이언트 IP · User-Agent 원문 — 로그인된 브라우저 목록 (계약 5절). 모르면 null */ readonly ip: string | null; readonly userAgent: string | null; + /** 이 갱신에서 다시 정한 제한 (13단계 — 계약 6절 "제한된 세션") — 없으면 null */ + readonly restriction: SessionRestriction | null; } /** 조건부 UPDATE … RETURNING id 의 결과 행 */ @@ -81,11 +84,75 @@ export class AuthSessionRepository { expiresAt: command.nextExpiresAt, ip: command.ip, userAgent: command.userAgent, + restriction: command.restriction, }, ); return result.affected === 1; } + /** + * 세션 하나의 제한을 바꾼다 — 지금 `from` 일 때만(조건부 UPDATE 한 번) (13단계 — 푸는 요청이 성공했다: 다음 제한으로 · 없으면 null). + * 그사이 다른 제한으로 바뀌었으면 건드리지 않는다. 바꿨으면 true. 끝난 세션도 상관없다(쓰이지 않는다) + */ + async replaceRestriction( + id: string, + from: SessionRestriction, + to: SessionRestriction | null, + ): Promise { + const result = await this.sessions.update( + { id, restriction: from }, + { restriction: to }, + ); + return (result.affected ?? 0) > 0; + } + + /** 세션 하나의 제한을 지운다 — 지금 그 제한일 때만 → 지웠으면 true */ + clearRestriction( + id: string, + restriction: SessionRestriction, + ): Promise { + return this.replaceRestriction(id, restriction, null); + } + + /** + * 그 직원의 살아 있는 세션 가운데 그 제한이 붙은 것을 끝낸다 — `keepId` 세션만 남긴다 → 끝낸 수 (13단계 — 2단계를 켰다: + * twoFactorSetup 이 붙은 다른 세션은 2단계를 거친 적이 없다). 끊은 세션 기억 · 15초 동기화를 탄다(revokeWhere) + */ + async revokeRestrictedOf( + userEmail: string, + restriction: SessionRestriction, + reason: SessionRevokedReason, + now: Date, + keepId: string | null, + ): Promise { + const ids = await this.revokeWhere( + `user_email = :userEmail AND restriction = :restriction AND revoked_at IS NULL AND expires_at > :now AND absolute_expires_at > :now${keepId !== null ? ' AND id <> :keepId' : ''}`, + { userEmail, restriction, now, ...(keepId !== null ? { keepId } : {}) }, + reason, + now, + ); + return ids.length; + } + + /** + * 세션 하나를 끝낸다 — 지금 그 제한이 붙어 있고 끝나지 않았을 때만(조건부 UPDATE 한 번) → 끝냈으면 true (13단계 — 갱신이 2단계를 거치지 않은 세션을 끊는다. + * 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않는다) + */ + async revokeIfRestricted( + id: string, + restriction: SessionRestriction, + reason: SessionRevokedReason, + now: Date, + ): Promise { + const ids = await this.revokeWhere( + 'id = :id AND restriction = :restriction AND revoked_at IS NULL', + { id, restriction }, + reason, + now, + ); + return ids.length > 0; + } + /** 세션을 끝낸다 — 이미 끝난 세션은 사유를 덮어쓰지 않는다. 끝냈으면 true */ async revoke( id: string, diff --git a/backend/src/modules/auth/auth-sessions.controller.ts b/backend/src/modules/auth/auth-sessions.controller.ts index 548e7da..a07970d 100644 --- a/backend/src/modules/auth/auth-sessions.controller.ts +++ b/backend/src/modules/auth/auth-sessions.controller.ts @@ -17,6 +17,7 @@ import { } from '@nestjs/swagger'; import type { Request } from 'express'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -34,9 +35,11 @@ import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto'; // 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다. // 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션) +// 13단계 — /api/auth/* 라 제한된 세션도 부른다(계약 6절 "제한된 세션") @ApiTags('Auth') @ApiCookieAuth() @UseGuards(JwtAuthGuard) +@AllowRestrictedSession() @ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음 · 만료 · 끊은 세션)') @Controller('auth/sessions') export class AuthSessionsController { diff --git a/backend/src/modules/auth/auth-sessions.service.spec.ts b/backend/src/modules/auth/auth-sessions.service.spec.ts index aa302c8..06d6f6b 100644 --- a/backend/src/modules/auth/auth-sessions.service.spec.ts +++ b/backend/src/modules/auth/auth-sessions.service.spec.ts @@ -31,6 +31,7 @@ const session = ( revokedReason: null, ip: '211.186.95.252', userAgent: CHROME, + restriction: null, ...extra, }); diff --git a/backend/src/modules/auth/auth-token.types.ts b/backend/src/modules/auth/auth-token.types.ts index a0afadd..128657d 100644 --- a/backend/src/modules/auth/auth-token.types.ts +++ b/backend/src/modules/auth/auth-token.types.ts @@ -1,3 +1,5 @@ +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; + /** * JWT 페이로드. * `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다. @@ -16,6 +18,13 @@ export interface AuthTokenPayload { * 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다 */ readonly sid?: string; + /** + * 제한된 세션의 제한 (13단계 — 계약 6절 "제한된 세션") — 액세스 토큰에만, 제한이 있을 때만 싣는다. + * 가드가 허용한 핸들러 밖이면 403 AUTH_010. 세션 표의 restriction 과 같은 값이다(로그인 · 갱신 때 정한다) + */ + readonly rst?: SessionRestriction; + /** 2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 액세스 토큰에만, 유예 중일 때만 */ + readonly tfd?: string; /** 토큰 id — 같은 순간에 발급한 토큰도 서로 다른 값(해시)이 되게 한다 */ readonly jti?: string; readonly iat?: number; diff --git a/backend/src/modules/auth/auth.controller.spec.ts b/backend/src/modules/auth/auth.controller.spec.ts index 77fc1b4..b5fc3e6 100644 --- a/backend/src/modules/auth/auth.controller.spec.ts +++ b/backend/src/modules/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { GUARDS_METADATA } from '@nestjs/common/constants'; import type { Request, Response } from 'express'; +import { ALLOW_RESTRICTED_SESSION } from '@/common/decorators/allow-restricted-session.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; import { AuthController } from './auth.controller'; @@ -22,6 +23,14 @@ const session = { refreshToken: 'new.refresh', refreshTtlSeconds: 1209600, }, + state: { restriction: null, twoFactorDeadline: null }, +}; + +/** 응답 본문 — 직원 + 제한 · 유예 (13단계) */ +const sessionBody = { + ...session.user, + restriction: null, + twoFactorDeadline: null, }; function fakeResponse() { @@ -60,6 +69,7 @@ describe('AuthController', () => { refresh: jest.Mock; logout: jest.Mock; changePassword: jest.Mock; + sessionStateOf: jest.Mock; }; let controller: AuthController; @@ -69,6 +79,9 @@ describe('AuthController', () => { refresh: jest.fn(), logout: jest.fn().mockResolvedValue(undefined), changePassword: jest.fn().mockResolvedValue(undefined), + sessionStateOf: jest + .fn() + .mockResolvedValue({ restriction: null, twoFactorDeadline: null }), }; controller = new AuthController(auth as unknown as AuthService, settings); }); @@ -118,7 +131,7 @@ describe('AuthController', () => { '203.0.113.10', { otpCode: '123456', userAgent: CHROME }, ); - expect(result).toEqual({ status: 'signedIn', ...session.user }); + expect(result).toEqual({ status: 'signedIn', ...sessionBody }); expect(res.cookie).toHaveBeenCalledTimes(2); }); @@ -147,6 +160,8 @@ describe('AuthController', () => { status: 'twoFactorRequired', email: null, name: null, + restriction: null, + twoFactorDeadline: null, }); expect(res.cookie).not.toHaveBeenCalled(); }); @@ -166,7 +181,7 @@ describe('AuthController', () => { ip: '203.0.113.10', userAgent: CHROME, }); - expect(user).toEqual(session.user); + expect(user).toEqual(sessionBody); expect(res.cookie).toHaveBeenCalledWith( 'access_token', 'new.access', @@ -180,7 +195,11 @@ describe('AuthController', () => { }); it('동시 갱신 유예(tokens: null)면 쿠키를 굽지 않는다 — 먼저 끝난 요청의 새 쿠키를 덮어쓰지 않게', async () => { - auth.refresh.mockResolvedValue({ user: session.user, tokens: null }); + auth.refresh.mockResolvedValue({ + user: session.user, + tokens: null, + state: { restriction: 'twoFactorSetup', twoFactorDeadline: null }, + }); const res = fakeResponse(); const user = await controller.refresh( @@ -189,7 +208,7 @@ describe('AuthController', () => { res as unknown as Response, ); - expect(user).toEqual(session.user); + expect(user).toEqual({ ...sessionBody, restriction: 'twoFactorSetup' }); expect(res.cookie).not.toHaveBeenCalled(); expect(res.clearCookie).not.toHaveBeenCalled(); }); @@ -278,4 +297,58 @@ describe('AuthController', () => { expect(auth.logout).toHaveBeenCalledWith('old.refresh'); expect(res.clearCookie).toHaveBeenCalledTimes(2); }); + + it('제한된 세션도 /api/auth/* 를 부른다 — 컨트롤러에 @AllowRestrictedSession (13단계)', () => { + expect(Reflect.getMetadata(ALLOW_RESTRICTED_SESSION, AuthController)).toBe( + true, + ); + }); + + it('me 는 서비스가 정한 세션 상태(세션 표의 지금 제한 · 정책으로 다시 본 유예)를 싣는다 (13단계)', async () => { + auth.sessionStateOf.mockResolvedValueOnce({ + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + const user = { + email: 'ara.kim@arasystem.test', + name: '김아라', + sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + }; + await expect(controller.me(user)).resolves.toEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + expect(auth.sessionStateOf).toHaveBeenCalledWith(user); + await expect( + controller.me({ email: 'ara.kim@arasystem.test', name: '김아라' }), + ).resolves.toEqual(sessionBody); + }); + + it('로그인 응답에 세션의 제한 · 유예를 싣는다 — 제한된 세션도 쿠키를 받는다 (13단계)', async () => { + auth.login.mockResolvedValue({ + ...session, + state: { + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }, + }); + const res = fakeResponse(); + + const result = await controller.login( + { email: 'ara.kim@arasystem.test', password: 'pw' }, + '203.0.113.10', + fakeRequest({}) as unknown as Request, + res as unknown as Response, + ); + + expect(result).toEqual({ + status: 'signedIn', + ...session.user, + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + expect(res.cookie).toHaveBeenCalledTimes(2); + }); }); diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 70fd982..94bb5c2 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; +import { AllowRestrictedSession } from '@/common/decorators/allow-restricted-session.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -43,8 +44,10 @@ import { LoginDto } from './dto/login.dto'; /** * 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다. * 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다. + * 13단계: `/api/auth/*` 는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다 — 로그인 · me · 갱신의 응답에 restriction · twoFactorDeadline 이 있다 */ @ApiTags('Auth') +@AllowRestrictedSession() @Controller('auth') export class AuthController { constructor( @@ -95,23 +98,46 @@ export class AuthController { userAgent: req.get('user-agent') ?? null, }); if (result.status === 'twoFactorRequired') { - return { status: result.status, email: null, name: null }; + return { + status: result.status, + email: null, + name: null, + restriction: null, + twoFactorDeadline: null, + }; } setSessionCookies(res, result.tokens, this.settings); - return { status: result.status, ...result.user }; + return { + status: result.status, + email: result.user.email, + name: result.user.name, + restriction: result.state.restriction, + twoFactorDeadline: result.state.twoFactorDeadline, + }; } @Get('me') @UseGuards(JwtAuthGuard) @ApiCookieAuth() - @ApiOperation({ summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)' }) + @ApiOperation({ + summary: '현재 로그인한 직원 (앱 시작 시 세션 복원용)', + description: + '13단계: restriction 은 세션 표의 지금 값(제한을 푼 뒤의 옛 토큰으로도 마친 단계가 다시 보이지 않는다), twoFactorDeadline 은 정책과 켜짐으로 다시 본 값이다.', + }) @ApiStandardResponse(AuthUserDto, { description: '로그인 상태' }) @ApiErrorResponse( 'AUTH_001', '로그인 안 됨 — 앱 시작 시에는 정상 상태이므로 알림을 띄우지 않는다', ) - me(@CurrentUser() user: AuthenticatedUser): AuthUserDto { - return { email: user.email, name: user.name }; + async me(@CurrentUser() user: AuthenticatedUser): Promise { + // 제한은 세션 표의 지금 값, 유예는 정책으로 다시 본 값 (13단계 — 판단은 AuthService) + const state = await this.auth.sessionStateOf(user); + return { + email: user.email, + name: user.name, + restriction: state.restriction, + twoFactorDeadline: state.twoFactorDeadline, + }; } /** @@ -125,7 +151,8 @@ export class AuthController { description: '액세스 쿠키가 없거나 만료돼도 동작한다. 실패(AUTH_001)하면 두 쿠키를 지운다. ' + '이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. 다른 탭이 방금 회전한 직후의 이전 토큰은 30초 동안 ' + - '새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다).', + '새 쿠키 없이 200 으로 답한다 (이긴 요청이 이미 새 쿠키를 구웠다). ' + + '13단계: 제한(restriction)은 갱신 때 다시 정한다 — 비밀번호 만료는 엔진에서 다시 알아채고, 2단계 인증 필수는 정책과 켜짐(우리 기록 → 엔진)으로 본다.', }) @ApiStandardResponse(AuthUserDto, { description: '회전 성공' }) @ApiErrorResponse( @@ -145,7 +172,12 @@ export class AuthController { ); // 동시 갱신 유예면 쿠키를 굽지 않는다 — 먼저 끝난 요청이 구운 새 쿠키를 덮어쓰지 않게 if (result.tokens) setSessionCookies(res, result.tokens, this.settings); - return result.user; + return { + email: result.user.email, + name: result.user.name, + restriction: result.state.restriction, + twoFactorDeadline: result.state.twoFactorDeadline, + }; } catch (error) { // 더는 쓸 수 없는 세션이면 쿠키를 지운다. 메일 엔진 장애처럼 일시적인 실패는 세션을 남긴다 if (error instanceof BusinessException && error.code === 'AUTH_001') { @@ -172,7 +204,8 @@ export class AuthController { '지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' + '지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' + '12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' + - '바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다.', + '바뀌면 앱 비밀번호도 모두 지운다(메일 프로그램은 새 앱 비밀번호로 다시 연결한다). 끊긴 세션의 액세스 토큰은 바로 막힌다. ' + + '13단계: 비밀번호가 만료된 세션(restriction passwordChange)의 푸는 길이다 — 지금 비밀번호는 임시 비밀번호. 바뀌면 이 세션의 제한을 지운다(클라이언트는 곧바로 /auth/refresh).', }) @ApiStandardResponse(undefined, { description: '바꿨다 (data: null)', diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index d681906..5e40a58 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -6,7 +6,12 @@ import type { AppPasswordsService } from '@/modules/account-security/app-passwor import type { LoginEventsService } from '@/modules/account-security/login-events.service'; import { TwoFactorCodeGuard } from '@/modules/account-security/two-factor-code-guard'; import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks'; -import type { MailProvider } from '@/modules/mail/mail.provider'; +import type { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service'; +import { + AccountLoginDisabledException, + PasswordExpiredException, + type MailProvider, +} from '@/modules/mail/mail.provider'; import type { CredentialCheck } from '@/modules/mail/mail.types'; import type { MailSettings } from '@/modules/mail/mail.settings'; import type { MailDirectoryService } from '@/modules/mail-directory/mail-directory.service'; @@ -100,6 +105,7 @@ class FakeSessions { expiresAt: command.nextExpiresAt, ip: command.ip, userAgent: command.userAgent, + restriction: command.restriction, }); } return Promise.resolve(alive); @@ -115,6 +121,39 @@ class FakeSessions { }, ); + /** 실제 저장소와 같은 조건 — 지금 그 제한이고 끝나지 않았을 때만 끝낸다 (13단계 — 재리뷰 N2) */ + readonly revokeIfRestricted = jest.fn( + ( + id: string, + restriction: AuthSessionEntity['restriction'], + reason: AuthSessionEntity['revokedReason'], + now: Date, + ) => { + const row = this.rows.get(id); + if (row && row.restriction === restriction && row.revokedAt === null) { + Object.assign(row, { revokedAt: now, revokedReason: reason }); + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + ); + + /** 실제 저장소와 같은 조건 — 지금 `from` 일 때만 바꾼다 (13단계) */ + readonly replaceRestriction = jest.fn( + ( + id: string, + from: AuthSessionEntity['restriction'], + to: AuthSessionEntity['restriction'], + ) => { + const row = this.rows.get(id); + if (row && row.restriction === from) { + row.restriction = to; + return Promise.resolve(true); + } + return Promise.resolve(false); + }, + ); + /** 실제 저장소와 같은 조건 — 그 직원의 살아 있는 세션 가운데 keepId 를 뺀 것만 끝낸다 */ readonly revokeAllOf = jest.fn( ( @@ -215,6 +254,11 @@ describe('AuthService', () => { let twoFactorLocks: TwoFactorLocks; let loginEvents: { recordWebmail: jest.Mock }; let appPasswords: { removeAllAfterPasswordChange: jest.Mock }; + /** 2단계 인증 필수 정책 대역 (13단계) — 처음은 제한 · 유예 없음 */ + let twoFactorPolicy: { + sessionState: jest.Mock; + isEnabledInEngine: jest.Mock; + }; let jwt: JwtService; let service: AuthService; let logs: string[]; @@ -232,6 +276,7 @@ describe('AuthService', () => { twoFactorLocks, loginEvents as unknown as LoginEventsService, appPasswords as unknown as AppPasswordsService, + twoFactorPolicy as unknown as TwoFactorPolicyService, ); const decode = (token: string) => @@ -278,6 +323,12 @@ describe('AuthService', () => { appPasswords = { removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0), }; + twoFactorPolicy = { + sessionState: jest + .fn() + .mockResolvedValue({ restriction: null, deadline: null }), + isEnabledInEngine: jest.fn().mockResolvedValue(false), + }; jwt = new JwtService({}); service = build(); @@ -1733,4 +1784,380 @@ describe('AuthService', () => { expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/); }); }); + + describe('13단계 — 제한된 세션 (계약 6절)', () => { + const expired = () => new PasswordExpiredException(ARA, 'ara.kim'); + const DEADLINE = new Date('2026-11-30T15:00:00.000Z'); + + it('비밀번호가 만료된 계정은 로그인하되 passwordChange 제한 — 쿠키를 받고 세션 · 액세스 토큰에 제한이 실린다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state).toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + expect(result.user).toEqual({ email: ARA, name: 'ara.kim' }); + expect(sessions.only().restriction).toBe('passwordChange'); + expect(decode(result.tokens.accessToken).rst).toBe('passwordChange'); + // 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다 + expect(directory.recordLogin).not.toHaveBeenCalled(); + // 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다 + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ email: ARA, isSuccess: true }), + ); + }); + + it('2단계 인증을 켠 직원도 코드 뒤에 만료를 알아챈다 — 402 의 정식 주소를 읽을 때 만료 예외로 정식 주소를 얻는다', async () => { + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + mail.getProfile.mockRejectedValue(expired()); + + const first = await service.login(ARA, PASSWORD, CLIENT_IP); + expect(first.status).toBe('twoFactorRequired'); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP, { + otpCode: '123456', + }); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state.restriction).toBe('passwordChange'); + // 켜짐은 엔진의 답(402)으로 안다 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true); + }); + + it('2단계 인증 필수가 시작됐고 꺼져 있으면 twoFactorSetup — 켜짐은 엔진의 답(200 = 꺼짐)으로 넘긴다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(twoFactorPolicy.sessionState).toHaveBeenCalledWith(ARA, false); + expect(result.state.restriction).toBe('twoFactorSetup'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + expect(directory.recordLogin).toHaveBeenCalled(); + }); + + it('비밀번호 만료가 2단계 필수보다 먼저다 — 유예의 시작 시각은 함께 싣는다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: DEADLINE, + }); + + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(result.state).toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: DEADLINE.toISOString(), + }); + const access = decode(result.tokens.accessToken); + expect(access.rst).toBe('passwordChange'); + expect(access.tfd).toBe(DEADLINE.toISOString()); + }); + + it('제한이 없으면 액세스 토큰에 rst · tfd 가 없다', async () => { + const result = await service.login(ARA, PASSWORD, CLIENT_IP); + if (result.status !== 'signedIn') throw new Error('로그인해야 한다'); + const access = decode(result.tokens.accessToken); + expect(access).not.toHaveProperty('rst'); + expect(access).not.toHaveProperty('tfd'); + expect(sessions.only().restriction).toBeNull(); + }); + + it('갱신 때 제한을 다시 정한다 — 엔진에서 만료를 다시 알아채고, 풀렸으면 정책(켜짐은 우리 기록 → 엔진)으로', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + + // 아직 바꾸지 않았다 — 갱신해도 passwordChange (세션은 끊지 않는다) + mail.getProfile.mockRejectedValueOnce(expired()); + const stillExpired = await service.refresh(login.tokens.refreshToken); + expect(stillExpired.state.restriction).toBe('passwordChange'); + expect(sessions.only().revokedAt).toBeNull(); + expect(sessions.only().restriction).toBe('passwordChange'); + + // 바꿨다 — 엔진의 만료가 풀렸고 필수 정책이 남았다 + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const next = await service.refresh(stillExpired.tokens!.refreshToken); + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + undefined, + expect.any(Date), + ); + expect(next.state.restriction).toBe('twoFactorSetup'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + expect(decode(next.tokens!.accessToken).rst).toBe('twoFactorSetup'); + expect(next.user.name).toBe('김아라'); + }); + + it('동시 갱신 유예의 응답은 먼저 회전한 요청이 세션 표에 적은 제한을 준다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const token = login.tokens.refreshToken; + await service.refresh(token); + + const grace = await service.refresh(token); + expect(grace.tokens).toBeNull(); + expect(grace.state.restriction).toBe('twoFactorSetup'); + }); + + it('비밀번호를 바꾸면 이 요청 세션(액세스 토큰의 sid)의 passwordChange 제한을 지운다 — 다음 제한이 없으면 null', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + expect(sessions.only().restriction).toBe('passwordChange'); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + + expect(sessions.replaceRestriction).toHaveBeenCalledWith( + sessionId, + 'passwordChange', + null, + ); + // 2단계는 꺼져 있다(엔진의 답 200) — 다음 제한은 정책과 그 값으로 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, false); + expect(sessions.only().restriction).toBeNull(); + expect(sessions.only().revokedAt).toBeNull(); + }); + + it('다음 제한이 남았으면 지우지 않고 그 제한으로 바꾼다 — 2단계 필수가 시작됐고 꺼져 있으면 twoFactorSetup (리뷰 M2)', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + // 비밀번호 만료가 먼저다 + expect(sessions.only().restriction).toBe('passwordChange'); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + + expect(sessions.only().restriction).toBe('twoFactorSetup'); + }); + + it('2단계를 켠 직원(402 길)의 비밀번호 바꾸기는 켜짐으로 다음 제한을 정한다', async () => { + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + sessions.rows.get(sessionId)!.restriction = 'passwordChange'; + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + twoFactorPolicy.sessionState.mockClear(); + + await service.changePassword( + { email: ARA, name: 'ara.kim', sessionId }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + '123456', + ); + + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith(ARA, true); + expect(sessions.only().restriction).toBeNull(); + }); + + it('제한 없는 세션의 보통 비밀번호 바꾸기는 제한을 붙이지 않는다 — 지금 passwordChange 일 때만 바꾼다', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + + await service.changePassword( + { email: ARA, name: '김아라', sessionId: sessions.only().id }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ); + expect(sessions.only().restriction).toBeNull(); + }); + + it('갱신 — 세션 표가 twoFactorSetup 인데 계정이 이미 켜짐이면 제한을 지우지 않고 세션을 끊는다(AUTH_001) (리뷰 M1)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + expect(sessions.only().restriction).toBe('twoFactorSetup'); + + // 다른 세션에서 2단계를 켰다 — 엔진에 묻는다(우리 기록이 아니라) + twoFactorPolicy.isEnabledInEngine.mockResolvedValueOnce(true); + await expect( + service.refresh(login.tokens.refreshToken), + ).rejects.toMatchObject({ code: 'AUTH_001' }); + expect(sessions.only()).toMatchObject({ + restriction: 'twoFactorSetup', + revokedReason: 'two-factor-not-verified', + }); + expect(sessions.only().revokedAt).not.toBeNull(); + expect(logs.join('\n')).toContain( + '2단계 인증을 거치지 않은 세션을 끊었습니다', + ); + }); + + it('갱신 — 그사이 2단계 켜기가 이 세션의 제한을 지웠으면 끊지 않고 보통 회전으로 이어 간다 (재리뷰 N2)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + + // 갱신이 엔진에 묻는 사이 이 세션이 켜기를 마쳐 제한을 지웠다 + twoFactorPolicy.isEnabledInEngine.mockImplementationOnce(() => { + sessions.rows.get(sessionId)!.restriction = null; + return Promise.resolve(true); + }); + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: null, + deadline: null, + }); + const next = await service.refresh(login.tokens.refreshToken); + expect(next.tokens).not.toBeNull(); + expect(next.state.restriction).toBeNull(); + expect(sessions.only().revokedAt).toBeNull(); + // 켜짐은 이미 알았다 — 정책에 그 값을 넘긴다 + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + true, + expect.any(Date), + ); + }); + + it('로그인 — 잠긴 계정(2단계를 켜 엔진이 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 · 실패로 센다 (실사용 시험)', async () => { + mail.verifyCredentials.mockResolvedValue(TWO_FACTOR); + mail.getProfile.mockRejectedValue(new AccountLoginDisabledException(ARA)); + + await expect( + service.login(ARA, PASSWORD, CLIENT_IP), + ).rejects.toMatchObject({ code: 'AUTH_004' }); + await expect( + service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: '123456' }), + ).rejects.toMatchObject({ code: 'AUTH_004' }); + // 틀린 비밀번호와 같은 기록 + expect(loginEvents.recordWebmail).toHaveBeenCalledWith( + expect.objectContaining({ + email: ARA, + isSuccess: false, + failureReason: 'wrongPassword', + }), + ); + expect(sessions.rows.size).toBe(0); + }); + + it('갱신 — 세션 표가 twoFactorSetup 이고 아직 꺼져 있으면 그대로 정책으로(켜짐을 한 번만 묻는다)', async () => { + twoFactorPolicy.sessionState.mockResolvedValue({ + restriction: 'twoFactorSetup', + deadline: null, + }); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + + const next = await service.refresh(login.tokens.refreshToken); + expect(next.state.restriction).toBe('twoFactorSetup'); + expect(twoFactorPolicy.isEnabledInEngine).toHaveBeenCalledTimes(1); + expect(twoFactorPolicy.sessionState).toHaveBeenLastCalledWith( + ARA, + false, + expect.any(Date), + ); + }); + + it('me 의 상태 — 제한은 세션 표의 지금 값(푼 뒤 옛 토큰이어도 null), 유예는 정책으로 다시 본다', async () => { + mail.getProfile.mockRejectedValueOnce(expired()); + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + const sessionId = sessions.only().id; + const tokenUser = { + email: ARA, + name: 'ara.kim', + sessionId, + restriction: 'passwordChange' as const, + twoFactorDeadline: null, + }; + + await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({ + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + + // 비밀번호를 바꿔 세션의 제한이 지워졌다 — 옛 토큰(rst 그대로)으로 물어도 제한 없음 + sessions.rows.get(sessionId)!.restriction = null; + twoFactorPolicy.sessionState.mockResolvedValueOnce({ + restriction: null, + deadline: DEADLINE, + }); + await expect(service.sessionStateOf(tokenUser)).resolves.toEqual({ + restriction: null, + twoFactorDeadline: DEADLINE.toISOString(), + }); + }); + + it('me 의 상태 — 정책을 읽지 못하면 토큰에 실린 유예를 그대로, 세션 id 가 없는 옛 토큰은 토큰의 제한', async () => { + twoFactorPolicy.sessionState.mockRejectedValueOnce( + new BusinessException('MAIL_001'), + ); + await expect( + service.sessionStateOf({ + email: ARA, + name: '김아라', + twoFactorDeadline: DEADLINE.toISOString(), + }), + ).resolves.toEqual({ + restriction: null, + twoFactorDeadline: DEADLINE.toISOString(), + }); + }); + + it('제한 바꾸기가 실패해도 비밀번호 바꾸기는 성공이다 — error 로그만', async () => { + const login = await service.login(ARA, PASSWORD, CLIENT_IP); + if (login.status !== 'signedIn') throw new Error('로그인해야 한다'); + sessions.replaceRestriction.mockRejectedValueOnce(new Error('db down')); + + await expect( + service.changePassword( + { email: ARA, name: '김아라' }, + PASSWORD, + 'new-password-123', + CLIENT_IP, + login.tokens.refreshToken, + ), + ).resolves.toBeUndefined(); + expect(logs.join('\n')).toContain('세션의 제한을 바꾸지 못했습니다'); + }); + }); }); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3d17c4b..914717a 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { randomUUID } from 'crypto'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { isAppPasswordShape } from '@/modules/account-security/app-password'; @@ -15,7 +16,13 @@ import { TwoFactorCodeGuard, } from '@/modules/account-security/two-factor-code-guard'; import { TwoFactorLocks } from '@/modules/account-security/two-factor-locks'; -import { MAIL_PROVIDER, type MailProvider } from '@/modules/mail/mail.provider'; +import { TwoFactorPolicyService } from '@/modules/account-security/two-factor-policy.service'; +import { + AccountLoginDisabledException, + MAIL_PROVIDER, + PasswordExpiredException, + type MailProvider, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { MailAccountProfile, @@ -50,6 +57,15 @@ import { type LoginAttemptTicket, } from './login-attempt.service'; +/** + * 세션의 제한 · 2단계 인증 필수의 유예 (13단계 — 계약 6절 "제한된 세션") — 로그인 · 갱신 · me 의 응답과 액세스 토큰에 싣는다 + */ +export interface SessionState { + readonly restriction: SessionRestriction | null; + /** 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null */ + readonly twoFactorDeadline: string | null; +} + export interface AuthResult { readonly user: AuthenticatedUser; /** @@ -57,6 +73,8 @@ export interface AuthResult { * 이긴 요청의 응답이 이미 새 쿠키를 구웠으므로 덮어쓰지 않는다. */ readonly tokens: SessionTokens | null; + /** 세션의 제한 · 유예 (13단계) */ + readonly state: SessionState; } /** @@ -67,6 +85,8 @@ export type LoginResult = readonly status: 'signedIn'; readonly user: AuthenticatedUser; readonly tokens: SessionTokens; + /** 세션의 제한 · 유예 (13단계 — 제한된 세션도 쿠키를 받는다) */ + readonly state: SessionState; } | { readonly status: 'twoFactorRequired'; @@ -89,6 +109,13 @@ export interface LoginOptions { readonly userAgent?: string | null; } +/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */ +interface LoadedProfile { + readonly profile: MailAccountProfile; + /** 엔진의 비밀번호가 만료됐다 — 세션에 passwordChange 제한을 붙인다 */ + readonly passwordExpired: boolean; +} + /** 리프레시 토큰에서 꺼낸 값 */ interface RefreshClaims { readonly sub: string; @@ -103,6 +130,14 @@ function toUser(profile: MailAccountProfile): AuthenticatedUser { return { email: profile.email, name: profile.name }; } +/** 비밀번호가 만료된 계정의 프로필 — 엔진이 알려 준 정식 주소와 메일 기능 없이 쓸 이름 (13단계) */ +function expiredProfile(error: PasswordExpiredException): LoadedProfile { + return { + profile: { email: error.account, name: error.displayName }, + passwordExpired: true, + }; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -146,6 +181,8 @@ export class AuthService { private readonly twoFactorLocks: TwoFactorLocks, private readonly loginEvents: LoginEventsService, private readonly appPasswords: AppPasswordsService, + /** 13단계 — 2단계 인증 필수 정책 (제한 twoFactorSetup · 유예의 시작 시각) */ + private readonly twoFactorPolicy: TwoFactorPolicyService, ) {} /** @@ -193,6 +230,7 @@ export class AuthService { }); let profile: MailAccountProfile; + let passwordExpired = false; let isTwoFactor = false; try { if (!loginAddress) { @@ -211,10 +249,18 @@ export class AuthService { throw this.loginFailed(address, 'rejected'); } if (check.kind === 'valid') { - profile = await this.profileOf(check.account, address); + ({ profile, passwordExpired } = await this.profileOf( + check.account, + address, + client, + )); } else { - // 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다) - profile = await this.profileOf(address, address); + // 402 — 비밀번호는 맞았다. 정식 주소를 먼저 얻는다(막는 계정 · 잠긴 계정이면 AUTH_004 — 아직 돌려주지 않은 실패로 센다) + ({ profile, passwordExpired } = await this.profileOf( + address, + address, + client, + )); // 코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다 ticket.verified(); if (options.otpCode == null) { @@ -242,8 +288,18 @@ export class AuthService { throw error; } - const tokens = await this.startSession(profile, client); - await this.recordInDirectory(profile); + // 제한(13단계): 비밀번호 만료가 먼저, 그다음 2단계 인증 필수 — 켜짐은 엔진의 답(402)으로 안다 + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(profile.email), + isTwoFactor, + ); + const state: SessionState = { + restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; + const tokens = await this.startSession(profile, client, state); + // 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다) + if (!passwordExpired) await this.recordInDirectory(profile); await this.loginEvents.recordWebmail({ email: profile.email, isSuccess: true, @@ -253,9 +309,9 @@ export class AuthService { userAgent: client.userAgent ?? null, }); this.logger.log( - `로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}`, + `로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`, ); - return { status: 'signedIn', user: toUser(profile), tokens }; + return { status: 'signedIn', user: toUser(profile), tokens, state }; } /** @@ -295,15 +351,16 @@ export class AuthService { private async profileOf( account: string, address: string, - ): Promise { + client: ClientInfo, + ): Promise { if (!this.isLoginAddress(account)) { throw this.loginFailed(address, 'rejected'); } - const profile = await this.loadProfileForLogin(account, address); - if (!this.isLoginAddress(profile.email)) { + const loaded = await this.loadProfileForLogin(account, address, client); + if (!this.isLoginAddress(loaded.profile.email)) { throw this.loginFailed(address, 'rejected'); } - return profile; + return loaded; } /** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */ @@ -435,6 +492,8 @@ export class AuthService { let releaseLock: (() => void) | null = null; try { let outcome: PasswordChangeOutcome; + // 2단계 인증이 켜졌는가 — 402 길이면 켜짐 (바꾼 뒤 다음 제한 twoFactorSetup 을 정할 때 쓴다) + let twoFactorEnabled = false; try { const check = await this.mail.verifyCredentials( address, @@ -450,6 +509,7 @@ export class AuthService { ); } else { // 402 — 비밀번호는 맞았다 (코드 단계가 끝날 때까지 같은 주소의 다른 시도는 처리 중이다) + twoFactorEnabled = true; ticket.verified(); if (otpCode == null) { throw this.otpCodeRequired(address); @@ -477,6 +537,12 @@ export class AuthService { } await this.settlePasswordChange(outcome, ticket, address, refreshToken); + await this.advancePasswordChangeRestriction( + user, + address, + refreshToken, + twoFactorEnabled, + ); } finally { releaseLock?.(); } @@ -585,6 +651,38 @@ export class AuthService { } } + /** + * 비밀번호를 바꿨다 — **이 요청의 세션**(액세스 토큰의 sid, 없으면 리프레시 쿠키)의 passwordChange 제한을 다음 제한으로 바꾼다 + * (13단계 — 계약 6절 "제한된 세션": 2단계 인증 필수가 시작됐고 아직 켜지 않았으면 twoFactorSetup, 아니면 null — 옛 토큰으로도 다음 단계 밖은 막힌다). + * 엔진은 바꾸는 순간 만료를 스스로 푼다. 지금 passwordChange 일 때만 바꾼다(조건부 UPDATE — 제한 없는 세션의 보통 바꾸기는 건드리지 않는다). + * 실패해도 던지지 않는다 — 비밀번호는 바뀌었고, 다음 갱신이 엔진 · 정책으로 다시 정한다 + */ + private async advancePasswordChangeRestriction( + user: AuthenticatedUser, + address: string, + refreshToken: string | undefined, + twoFactorEnabled: boolean, + ): Promise { + const sessionId = + user.sessionId ?? (await this.currentSessionId(refreshToken, address)); + if (sessionId === null) return; + try { + const next = await this.twoFactorPolicy.sessionState( + address, + twoFactorEnabled, + ); + await this.sessions.replaceRestriction( + sessionId, + 'passwordChange', + next.restriction, + ); + } catch (error) { + this.logger.error( + `[SYS_001]${traceTag()} 비밀번호를 바꿨지만 세션의 제한을 바꾸지 못했습니다 — 다음 갱신이 다시 정합니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`, + ); + } + } + private currentPasswordRejected( address: string, reason: string, @@ -647,6 +745,35 @@ export class AuthService { } } + /** + * me 의 세션 상태 (13단계 — 계약 6절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게), + * 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다. + * 읽지 못하면 액세스 토큰의 값을 쓴다 (앱 시작이 막히지 않게) + */ + async sessionStateOf(user: AuthenticatedUser): Promise { + let restriction = user.restriction ?? null; + if (user.sessionId) { + try { + const row = await this.sessions.findById(user.sessionId); + restriction = row?.restriction ?? null; + } catch (error) { + this.logger.warn( + `[SYS_001]${traceTag()} 세션의 제한을 읽지 못해 토큰의 값으로 답합니다: user=${maskEmail(user.email)}, reason=${errorMessage(error)}`, + ); + } + } + let twoFactorDeadline = user.twoFactorDeadline ?? null; + try { + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(user.email), + ); + twoFactorDeadline = twoFactor.deadline?.toISOString() ?? null; + } catch { + // 정책 · 엔진을 읽지 못했다 — 토큰에 실린 값(로그인 · 갱신 때 정한 것)으로 답한다 + } + return { restriction, twoFactorDeadline }; + } + /** * 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200): * 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다. @@ -712,8 +839,12 @@ export class AuthService { } if (state === 'grace') { - const profile = await this.mail.getProfile(claims.sub); - return { user: toUser(profile), tokens: null }; + const { profile } = await this.profileForGrace(claims.sub); + return { + user: toUser(profile), + tokens: null, + state: await this.graceState(session, profile.email), + }; } if (state === 'reused') { @@ -735,13 +866,58 @@ export class AuthService { now: Date, client: ClientInfo, ): Promise { - const profile = await this.loadProfileForRefresh(claims, now); + const { profile, passwordExpired } = await this.loadProfileForRefresh( + claims, + now, + ); + const address = normalizeEmail(profile.email); + // 2단계를 거친 적이 없는 세션(twoFactorSetup)인데 계정은 이미 켜짐 — 다른 세션에서 켰다. 제한을 지우지 않고 끊는다 (13단계 — 계약 6절). + // 켜짐은 엔진에 묻는다(우리 기록이 오래됐으면 갱신마다 끊기므로 — 재리뷰 N1). 끊기는 조건부 — 그사이 이 세션이 켜 제한이 지워졌으면 보통 회전으로(N2) + let knownEnabled: boolean | undefined; + if (session.restriction === 'twoFactorSetup') { + knownEnabled = await this.twoFactorPolicy.isEnabledInEngine(address); + if ( + knownEnabled && + (await this.sessions.revokeIfRestricted( + claims.sid, + 'twoFactorSetup', + 'two-factor-not-verified', + now, + )) + ) { + this.logger.warn( + `[AUTH_001]${traceTag()} 2단계 인증을 거치지 않은 세션을 끊었습니다 — 그사이 계정에 2단계가 켜졌습니다: user=${maskEmail(address)}, sid=${claims.sid}`, + ); + throw new BusinessException('AUTH_001', { + context: { + reason: 'session-two-factor-not-verified', + sid: claims.sid, + }, + }); + } + } + // 제한은 갱신 때 다시 정한다(13단계): 비밀번호 만료(엔진에서 다시 알아챈다)가 먼저, 그다음 정책 — 켜짐은 우리 기록 → 엔진 + const twoFactor = await this.twoFactorPolicy.sessionState( + address, + knownEnabled, + now, + ); + const sessionState: SessionState = { + restriction: passwordExpired ? 'passwordChange' : twoFactor.restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; const expiresAt = nextIdleExpiry( now, this.settings.refreshTtlSeconds, session.absoluteExpiresAt, ); - const tokens = await this.signTokens(profile, claims.sid, expiresAt, now); + const tokens = await this.signTokens( + profile, + claims.sid, + expiresAt, + now, + sessionState, + ); const rotated = await this.sessions.rotate({ id: claims.sid, @@ -751,13 +927,18 @@ export class AuthService { now, ip: client.ip || null, userAgent: truncateUserAgent(client.userAgent), + restriction: sessionState.restriction, }); - if (rotated) return { user: toUser(profile), tokens }; + if (rotated) { + return { user: toUser(profile), tokens, state: sessionState }; + } // 그 사이 다른 요청이 먼저 회전했거나 세션을 끊었다 — 지금 상태로 다시 판단한다 const latest = await this.sessions.findById(claims.sid); const state = this.classify(latest, claims, presentedHash, now); - if (state === 'grace') return { user: toUser(profile), tokens: null }; + if (state === 'grace') { + return { user: toUser(profile), tokens: null, state: sessionState }; + } if (state === 'current') { // 조건부 UPDATE 와 같은 조건인데 바뀌지 않았다 — 서버 쪽 불일치(시계 어긋남 등)라 사용자 잘못이 아니다. // 세션을 끊지 않는다: AUTH_001 이 아니라 SYS_001(500)로 답해 컨트롤러가 쿠키를 지우지 않게 하고, @@ -780,10 +961,17 @@ export class AuthService { private async loadProfileForRefresh( claims: RefreshClaims, now: Date, - ): Promise { + ): Promise { try { - return await this.mail.getProfile(claims.sub, { fresh: true }); + return { + profile: await this.mail.getProfile(claims.sub, { fresh: true }), + passwordExpired: false, + }; } catch (error) { + // 비밀번호가 만료됐다(관리자가 재설정했거나 아직 바꾸지 않았다) — 세션은 남기고 passwordChange 제한을 붙인다 (13단계) + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } if (error instanceof BusinessException && error.code === 'AUTH_001') { await this.sessions.revoke(claims.sid, 'account-unavailable', now); } @@ -795,10 +983,24 @@ export class AuthService { private async loadProfileForLogin( account: string, address: string, - ): Promise { + client: ClientInfo, + ): Promise { try { - return await this.mail.getProfile(account, { fresh: true }); + return { + profile: await this.mail.getProfile(account, { fresh: true }), + passwordExpired: false, + }; } catch (error) { + // 비밀번호는 맞았고 엔진의 비밀번호가 만료됐다 — 로그인은 하되 새 비밀번호 단계로 보낸다 (13단계 — 계약 6절) + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } + if (error instanceof AccountLoginDisabledException) { + // 잠긴 계정 — 2단계를 켰으면 엔진이 2단계를 먼저 봐 비밀번호가 맞을 때만 여기 온다. 틀린 비밀번호와 같은 AUTH_004 · 기록 · 실패 한도로 + // (잠긴 · 털린 계정의 비밀번호가 맞는지 바깥에서 알 수 없게 — 2단계가 꺼진 잠긴 계정은 엔진이 401 로 답해 이미 그렇다) + await this.recordLoginFailure(address, client, 'wrongPassword'); + throw this.loginFailed(address, 'account-login-disabled'); + } if (error instanceof BusinessException && error.code === 'AUTH_001') { throw this.loginFailed(address, 'mailbox-unavailable'); } @@ -810,6 +1012,7 @@ export class AuthService { private async startSession( profile: MailAccountProfile, client: ClientInfo, + state: SessionState, ): Promise { const now = new Date(); const id = randomUUID(); @@ -821,7 +1024,7 @@ export class AuthService { this.settings.refreshTtlSeconds, absoluteExpiresAt, ); - const tokens = await this.signTokens(profile, id, expiresAt, now); + const tokens = await this.signTokens(profile, id, expiresAt, now, state); await this.sessions.create({ id, @@ -836,6 +1039,7 @@ export class AuthService { revokedReason: null, ip: client.ip || null, userAgent: truncateUserAgent(client.userAgent), + restriction: state.restriction, }); return tokens; } @@ -884,6 +1088,7 @@ export class AuthService { sessionId: string, refreshExpiresAt: Date, now: Date, + state: SessionState, ): Promise { const { jwtSecret, accessTtlSeconds } = this.settings; const refreshTtlSeconds = Math.max( @@ -891,11 +1096,16 @@ export class AuthService { Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000), ); // 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저") + // 제한 · 유예(13단계)도 싣는다 — 가드가 제한을 보고 me 가 그대로 돌려준다 (있을 때만) const access: AuthTokenPayload = { sub: profile.email, typ: 'access', name: profile.name, sid: sessionId, + ...(state.restriction !== null ? { rst: state.restriction } : {}), + ...(state.twoFactorDeadline !== null + ? { tfd: state.twoFactorDeadline } + : {}), }; const refresh: AuthTokenPayload = { sub: profile.email, @@ -921,6 +1131,43 @@ export class AuthService { return { accessToken, refreshToken, refreshTtlSeconds }; } + /** 동시 갱신 유예의 프로필 — 기억한 세션으로 읽는다. 비밀번호가 만료된 계정은 주소의 @ 앞을 이름으로 (13단계) */ + private async profileForGrace(email: string): Promise { + try { + return { + profile: await this.mail.getProfile(email), + passwordExpired: false, + }; + } catch (error) { + if (error instanceof PasswordExpiredException) { + return expiredProfile(error); + } + throw error; + } + } + + /** + * 동시 갱신 유예의 상태 — 제한은 먼저 회전한 요청이 세션 표에 적은 값, 유예의 시작 시각은 정책으로 (13단계). + * 정책을 읽지 못하면 유예 없이 답한다 — 이긴 요청의 응답이 이미 같은 값을 주었다 + */ + private async graceState( + session: AuthSessionEntity | null, + email: string, + ): Promise { + const restriction = session?.restriction ?? null; + try { + const twoFactor = await this.twoFactorPolicy.sessionState( + normalizeEmail(email), + ); + return { + restriction, + twoFactorDeadline: twoFactor.deadline?.toISOString() ?? null, + }; + } catch { + return { restriction, twoFactorDeadline: null }; + } + } + private loginFailed(email: string, reason: string): BusinessException { return new BusinessException('AUTH_004', { context: { reason, email: maskEmail(email) }, diff --git a/backend/src/modules/auth/dto/auth-user.dto.ts b/backend/src/modules/auth/dto/auth-user.dto.ts index 1eb8269..c2b1a0d 100644 --- a/backend/src/modules/auth/dto/auth-user.dto.ts +++ b/backend/src/modules/auth/dto/auth-user.dto.ts @@ -1,7 +1,31 @@ import { ApiProperty } from '@nestjs/swagger'; +import { + SESSION_RESTRICTIONS, + type SessionRestriction, +} from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; +/** 제한 칸의 Swagger 설명 — 로그인 · me · 갱신이 같은 글을 쓴다 */ +export const RESTRICTION_PROPERTY = { + description: + '제한된 세션 (13단계 — 계약 6절) — passwordChange(새 비밀번호를 정할 때까지) · twoFactorSetup(2단계 인증을 켤 때까지) · null(제한 없음). ' + + '제한된 동안 다른 API 는 403 AUTH_010 이다', + enum: SESSION_RESTRICTIONS, + nullable: true, + example: null, +} as const; + +/** 유예 칸의 Swagger 설명 */ +export const TWO_FACTOR_DEADLINE_PROPERTY = { + description: + '2단계 인증 필수의 유예 중이면 시작일 0시(KST)의 시각 (ISO 8601 UTC) — 아니면 null. 화면은 메일함 위에 그 1ms 앞 날짜(시작일 전날)까지로 안내한다', + type: String, + format: 'date-time', + nullable: true, + example: null, +} as const; + /** 로그인한 직원 — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다 */ export class AuthUserDto implements AuthenticatedUser { @ApiProperty({ @@ -12,4 +36,10 @@ export class AuthUserDto implements AuthenticatedUser { @ApiProperty({ description: '표시 이름', example: '김아라' }) name!: string; + + @ApiProperty(RESTRICTION_PROPERTY) + restriction!: SessionRestriction | null; + + @ApiProperty(TWO_FACTOR_DEADLINE_PROPERTY) + twoFactorDeadline!: string | null; } diff --git a/backend/src/modules/auth/dto/login-result.dto.ts b/backend/src/modules/auth/dto/login-result.dto.ts index a2c28b7..f2500f4 100644 --- a/backend/src/modules/auth/dto/login-result.dto.ts +++ b/backend/src/modules/auth/dto/login-result.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; +import { + RESTRICTION_PROPERTY, + TWO_FACTOR_DEADLINE_PROPERTY, +} from './auth-user.dto'; + /** * POST /api/auth/login 의 응답 (계약 5절 "로그인") — 토큰은 본문에 없고 HttpOnly 쿠키로만 간다. * twoFactorRequired 는 비밀번호가 맞았다는 뜻이다 — 쿠키가 없고 email · name 은 null (코드와 비밀번호를 함께 다시 보낸다) @@ -28,4 +34,16 @@ export class LoginResultDto { example: '김아라', }) name!: string | null; + + @ApiProperty({ + ...RESTRICTION_PROPERTY, + description: `${RESTRICTION_PROPERTY.description}. twoFactorRequired 면 null — 제한된 세션도 쿠키를 받는다`, + }) + restriction!: SessionRestriction | null; + + @ApiProperty({ + ...TWO_FACTOR_DEADLINE_PROPERTY, + description: `${TWO_FACTOR_DEADLINE_PROPERTY.description}. twoFactorRequired 면 null`, + }) + twoFactorDeadline!: string | null; } diff --git a/backend/src/modules/auth/jwt.strategy.spec.ts b/backend/src/modules/auth/jwt.strategy.spec.ts index d0c7cea..228241a 100644 --- a/backend/src/modules/auth/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/jwt.strategy.spec.ts @@ -1,4 +1,6 @@ import { BusinessException } from '@/common/errors/business.exception'; +import type { AuthSessionEntity } from './auth-session.entity'; +import type { AuthSessionRepository } from './auth-session.repository'; import { JwtStrategy } from './jwt.strategy'; import type { RevokedSessions } from './revoked-sessions'; @@ -8,6 +10,14 @@ const revoked = { isRevoked: (sessionId: string) => revokedIds.has(sessionId), } as unknown as RevokedSessions; +/** 세션 표 대역 — 제한이 실린 토큰만 읽는다 (13단계) */ +const rows = new Map>(); +const sessions = { + findById: jest.fn((id: string) => + Promise.resolve((rows.get(id) as AuthSessionEntity | undefined) ?? null), + ), +}; + const strategy = new JwtStrategy( { jwtSecret: 'unit-test-secret-unit-test-secret-1234', @@ -16,11 +26,16 @@ const strategy = new JwtStrategy( secureCookies: false, }, revoked, + sessions as unknown as AuthSessionRepository, ); -function codeOf(run: () => unknown): string | undefined { +const SID = '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a'; + +async function codeOf( + run: () => Promise, +): Promise { try { - run(); + await run(); } catch (error) { return error instanceof BusinessException ? error.code : 'NOT_BUSINESS'; } @@ -28,20 +43,24 @@ function codeOf(run: () => unknown): string | undefined { } describe('JwtStrategy.validate', () => { - afterEach(() => revokedIds.clear()); + afterEach(() => { + revokedIds.clear(); + rows.clear(); + sessions.findById.mockClear(); + }); - it('액세스 토큰이면 직원 정보를 돌려준다', () => { - expect( + it('액세스 토큰이면 직원 정보를 돌려준다', async () => { + await expect( strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', }), - ).toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' }); + ).resolves.toEqual({ email: 'ara.kim@arasystem.test', name: '김아라' }); }); - it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', () => { - const user = strategy.validate({ + it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', async () => { + const user = await strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', @@ -55,36 +74,38 @@ describe('JwtStrategy.validate', () => { }); }); - it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', () => { - expect( + it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', async () => { + await expect( strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', - sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sid: SID, }), - ).toStrictEqual({ + ).resolves.toStrictEqual({ email: 'ara.kim@arasystem.test', name: '김아라', - sessionId: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sessionId: SID, }); + // 제한이 없는 토큰은 DB 를 묻지 않는다 + expect(sessions.findById).not.toHaveBeenCalled(); }); - it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', () => { - revokedIds.add('9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a'); + it('끊은 세션의 액세스 토큰은 만료 전이어도 AUTH_001 — DB 를 묻지 않고 끊은 세션 기억으로 막는다 (계약 5절)', async () => { + revokedIds.add(SID); expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', name: '김아라', - sid: '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a', + sid: SID, }), ), ).toBe('AUTH_001'); // 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다 expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', @@ -94,7 +115,7 @@ describe('JwtStrategy.validate', () => { ), ).toBeUndefined(); expect( - codeOf(() => + await codeOf(() => strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'access', @@ -104,15 +125,88 @@ describe('JwtStrategy.validate', () => { ).toBeUndefined(); }); - it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', () => { - expect(() => - strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), - ).toThrow(BusinessException); + it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', async () => { + expect( + await codeOf(() => + strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }), + ), + ).toBe('AUTH_001'); + }); - try { - strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }); - } catch (error) { - expect((error as BusinessException).code).toBe('AUTH_001'); + it('제한이 실린 토큰은 세션 표의 지금 제한을 쓴다 · 유예의 시작 시각은 토큰 그대로 (13단계)', async () => { + rows.set(SID, { id: SID, restriction: 'passwordChange', revokedAt: null }); + await expect( + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: SID, + rst: 'passwordChange', + tfd: '2026-11-30T15:00:00.000Z', + }), + ).resolves.toStrictEqual({ + email: 'ara.kim@arasystem.test', + name: '김아라', + sessionId: SID, + restriction: 'passwordChange', + twoFactorDeadline: '2026-11-30T15:00:00.000Z', + }); + }); + + it('제한을 푼 뒤의 옛 토큰은 제한 없음으로 — 세션 표가 지웠으면 null (마친 단계로 되돌아가지 않게)', async () => { + rows.set(SID, { id: SID, restriction: null, revokedAt: null }); + const user = await strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + sid: SID, + rst: 'passwordChange', + }); + expect(user.restriction).toBeNull(); + }); + + it('제한이 실렸는데 세션 행이 없거나 끝났거나 sid 가 없으면 AUTH_001', async () => { + for (const payload of [{ sid: SID }, { sid: undefined }]) { + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'twoFactorSetup', + ...payload, + }), + ), + ).toBe('AUTH_001'); } + rows.set(SID, { + id: SID, + restriction: 'twoFactorSetup', + revokedAt: new Date(), + }); + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'twoFactorSetup', + sid: SID, + }), + ), + ).toBe('AUTH_001'); + }); + + it('모르는 제한이 실린 토큰은 AUTH_001 — 제한을 모르고 통과시키지 않는다', async () => { + expect( + await codeOf(() => + strategy.validate({ + sub: 'ara.kim@arasystem.test', + typ: 'access', + name: '김아라', + rst: 'somethingElse' as never, + }), + ), + ).toBe('AUTH_001'); }); }); diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 658c5fc..0559f87 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -3,12 +3,14 @@ import { PassportStrategy } from '@nestjs/passport'; import type { Request } from 'express'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { isSessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator'; import { BusinessException } from '@/common/errors/business.exception'; import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard'; import { readCookie } from '@/shared/utils/cookies'; import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants'; import { AUTH_SETTINGS, type AuthSettings } from './auth.settings'; +import { AuthSessionRepository } from './auth-session.repository'; import type { AuthTokenPayload } from './auth-token.types'; import { RevokedSessions } from './revoked-sessions'; @@ -24,12 +26,15 @@ function accessTokenFromCookie(request: Request): string | null { * 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다. * 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다 * (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다). + * 13단계: 제한(`rst`)이 실린 토큰만 세션 표의 **지금 제한**을 읽어 쓴다 — 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 세션의 제한을 지운 뒤 + * 옛 토큰으로 온 요청(새로고침 · 다른 탭)이 마친 단계로 되돌아가지 않게. 제한이 없는 토큰은 지금처럼 DB 를 묻지 않는다 */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { constructor( @Inject(AUTH_SETTINGS) settings: AuthSettings, private readonly revoked: RevokedSessions, + private readonly sessions: AuthSessionRepository, ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]), @@ -39,7 +44,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { }); } - validate(payload: AuthTokenPayload): AuthenticatedUser { + async validate(payload: AuthTokenPayload): Promise { if ( payload.typ !== 'access' || typeof payload.sub !== 'string' || @@ -50,6 +55,12 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { context: { reason: 'not-an-access-token' }, }); } + if (payload.rst !== undefined && !isSessionRestriction(payload.rst)) { + // 서명은 맞는데 모르는 제한 — 이 서버가 만든 토큰이 아니거나 판이 다르다. 제한을 모르고 통과시키지 않는다 (닫힌 쪽) + throw new BusinessException('AUTH_001', { + context: { reason: 'unknown-session-restriction' }, + }); + } const sessionId = typeof payload.sid === 'string' ? payload.sid : null; if (sessionId !== null && this.revoked.isRevoked(sessionId)) { // 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다 @@ -65,6 +76,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { ? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND } : {}), ...(sessionId !== null ? { sessionId } : {}), + // 제한된 세션 · 2단계 인증 필수의 유예 (13단계) — 있을 때만. 가드가 제한을 본다(세션 표의 지금 값) + ...(payload.rst !== undefined + ? { restriction: await this.currentRestriction(sessionId) } + : {}), + ...(typeof payload.tfd === 'string' + ? { twoFactorDeadline: payload.tfd } + : {}), }; } + + /** + * 제한이 실린 토큰 — 세션 표의 지금 제한 (13단계). 행이 없거나 끝난 세션이면 AUTH_001(갱신이 다시 정한다). + * 제한은 sid 가 있는 토큰(12단계 뒤)에만 실린다 — sid 가 없으면 받지 않는다 + */ + private async currentRestriction( + sessionId: string | null, + ): Promise { + const row = + sessionId === null ? null : await this.sessions.findById(sessionId); + if (!row || row.revokedAt !== null) { + throw new BusinessException('AUTH_001', { + context: { reason: 'restricted-session-missing', sid: sessionId }, + }); + } + return row.restriction; + } } diff --git a/backend/src/modules/auth/revoked-sessions.spec.ts b/backend/src/modules/auth/revoked-sessions.spec.ts index e02eee7..c11b581 100644 --- a/backend/src/modules/auth/revoked-sessions.spec.ts +++ b/backend/src/modules/auth/revoked-sessions.spec.ts @@ -1,8 +1,12 @@ +import { Logger } from '@nestjs/common'; import { MoreThan, type Repository } from 'typeorm'; import { REVOKED_SESSION_MEMORY_MS } from './auth.constants'; import type { AuthSessionEntity } from './auth-session.entity'; -import { RevokedSessions } from './revoked-sessions'; +import { + REVOKED_SESSION_SYNC_INTERVAL_MS, + RevokedSessions, +} from './revoked-sessions'; const NOW = Date.parse('2026-10-31T03:00:00Z'); @@ -62,4 +66,80 @@ describe('RevokedSessions (끊은 세션 id 기억 — 계약 5절 "로그인된 expect(revoked.size).toBe(1); expect(revoked.isRevoked('fresh', NOW)).toBe(true); }); + + describe('15초 DB 동기화 (13단계 — 운영 도구가 끊은 세션)', () => { + it('다른 프로세스가 끊은 세션을 다음 동기화에서 읽어 막는다', async () => { + await revoked.onApplicationBootstrap(); + expect(revoked.isRevoked('by-ops', NOW)).toBe(false); + + repository.find.mockResolvedValue([ + { id: 'by-ops', revokedAt: new Date(NOW + 5_000) }, + ]); + await expect( + revoked.syncFromDatabase(NOW + REVOKED_SESSION_SYNC_INTERVAL_MS), + ).resolves.toBe(1); + expect(revoked.isRevoked('by-ops', NOW + 20_000)).toBe(true); + }); + + it('창은 지난 동기화를 시작한 때보다 60초 앞부터 — 늦게 커밋된 끊기도 잡는다', async () => { + await revoked.onApplicationBootstrap(); // 지난 동기화 = NOW + repository.find.mockClear(); + await revoked.syncFromDatabase(NOW + 15_000); + expect(repository.find).toHaveBeenCalledWith({ + select: { id: true, revokedAt: true }, + where: { revokedAt: MoreThan(new Date(NOW - 60_000)) }, + order: { revokedAt: 'ASC' }, + }); + repository.find.mockClear(); + await revoked.syncFromDatabase(NOW + 30_000); + expect(repository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { revokedAt: MoreThan(new Date(NOW + 15_000 - 60_000)) }, + }), + ); + }); + + it('오래 읽지 못했어도 창은 1시간보다 앞서지 않는다', async () => { + await revoked.syncFromDatabase(NOW); // 처음 — 1시간 전부터 + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { + revokedAt: MoreThan(new Date(NOW - REVOKED_SESSION_MEMORY_MS)), + }, + }), + ); + const later = NOW + 3 * REVOKED_SESSION_MEMORY_MS; + await revoked.syncFromDatabase(later); + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { + revokedAt: MoreThan(new Date(later - REVOKED_SESSION_MEMORY_MS)), + }, + }), + ); + }); + + it('DB 를 읽지 못해도 던지지 않고(warn 한 번) 다음 주기에 그사이의 끊기까지 읽는다', async () => { + await revoked.onApplicationBootstrap(); + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + repository.find.mockRejectedValue(new Error('db down')); + await expect(revoked.syncFromDatabase(NOW + 15_000)).resolves.toBe(0); + await expect(revoked.syncFromDatabase(NOW + 30_000)).resolves.toBe(0); + expect(warn).toHaveBeenCalledTimes(1); + + repository.find.mockResolvedValue([ + { id: 'while-down', revokedAt: new Date(NOW + 10_000) }, + ]); + await revoked.syncFromDatabase(NOW + 45_000); + // 지난 성공(NOW) - 60초부터 읽는다 — 실패한 동안 끊긴 것도 든다 + expect(repository.find).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { revokedAt: MoreThan(new Date(NOW - 60_000)) }, + }), + ); + expect(revoked.isRevoked('while-down', NOW + 45_000)).toBe(true); + }); + }); }); diff --git a/backend/src/modules/auth/revoked-sessions.ts b/backend/src/modules/auth/revoked-sessions.ts index 4419770..beb3d60 100644 --- a/backend/src/modules/auth/revoked-sessions.ts +++ b/backend/src/modules/auth/revoked-sessions.ts @@ -9,6 +9,22 @@ import { AuthSessionEntity } from './auth-session.entity'; /** 지난 기억을 지우는 주기 */ const SWEEP_INTERVAL_MS = 60_000; +/** + * DB 에서 끊은 세션을 다시 채우는 주기 (13단계 — 계약 6절 "도구로 끊은 세션도 15초 안에 막힌다"). + * 운영 도구(dist/cli/ops.js)는 다른 프로세스라 그 프로세스의 기억은 이 서버에 닿지 않는다 — 세션 끊기는 DB 로 하고, 서버는 이 주기로 DB 를 읽는다 + */ +export const REVOKED_SESSION_SYNC_INTERVAL_MS = 15_000; + +/** + * 동기화 창의 겹침 — 지난 동기화를 시작한 때보다 이만큼 앞에서부터 다시 읽는다. + * 다른 프로세스가 `revoked_at` 을 적은 뒤 늦게 커밋해도(그사이 동기화가 지나가도) 다음 동기화가 잡는다. 겹쳐 읽은 세션은 기억이 하나로 합친다 + */ +const SYNC_OVERLAP_MS = 60_000; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** * 끊은 세션 id 기억 (계약 5절 "로그인된 브라우저" — 끊은 세션은 바로 막는다). * @@ -19,15 +35,21 @@ const SWEEP_INTERVAL_MS = 60_000; * 세션 표의 조건부 UPDATE 가 끝낸 id 를 여기에 넣는다 (auth-session.repository.ts — 한 곳) * - 서버가 다시 뜨면(onApplicationBootstrap) 최근 1시간 안에 끊은 세션을 DB 에서 읽어 채운다 — 읽지 못하면 기동을 멈춘다 * (끊은 세션의 토큰이 만료까지 다시 쓰이는 일이 없게. 운영은 마이그레이션도 기동 때 DB 에 하므로 DB 없이는 어차피 뜨지 않는다) + * - 13단계: 15초마다(REVOKED_SESSION_SYNC_INTERVAL_MS) DB 에서 새로 끊긴 세션을 읽어 더한다 — 운영 도구가 끊은 세션(다른 프로세스)도 15초 안에 막힌다. + * 읽지 못하면 다음 주기에 다시 — 겹침(SYNC_OVERLAP_MS)이 있어 그사이 끊긴 것도 잡는다. 인스턴스를 늘려도 15초 안에 맞는다 * - 12단계 전에 발급한 토큰(`sid` 없음)은 막지 못한다 — 만료(최대 1시간)까지 쓰인다 - * - * ⚠️ 프로세스 메모리다 — 인스턴스를 늘리면 한 인스턴스에서 끊은 세션을 다른 인스턴스가 모른다(공유 저장소로 옮긴다 — nestjs 스킬 "확장 때 옮길 것"). */ @Injectable() export class RevokedSessions implements OnApplicationBootstrap { private readonly logger = new Logger(RevokedSessions.name); /** 세션 id → 끊은 시각(epoch ms) */ private readonly revokedAt = new Map(); + /** 마지막으로 DB 에서 채우기를 시작한 때 (epoch ms) — null 이면 아직 채우지 않았다 */ + private lastSyncedAt: number | null = null; + /** 동기화가 도는 중인가 — 한 번에 하나만 */ + private syncing = false; + /** 동기화 실패를 알렸는가 — 이어지는 실패는 회복할 때까지 한 번만 warn */ + private syncFailureReported = false; constructor( @InjectRepository(AuthSessionEntity) @@ -36,19 +58,47 @@ export class RevokedSessions implements OnApplicationBootstrap { /** 기동 — 최근 1시간 안에 끊은 세션을 채운다 (revoked_at 부분 인덱스를 탄다) */ async onApplicationBootstrap(): Promise { - const since = new Date(Date.now() - REVOKED_SESSION_MEMORY_MS); - const rows = await this.sessions.find({ - select: { id: true, revokedAt: true }, - where: { revokedAt: MoreThan(since) }, - order: { revokedAt: 'ASC' }, - }); - for (const row of rows) { - if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime()); + const startedAt = Date.now(); + const loaded = await this.loadRevokedSince( + startedAt - REVOKED_SESSION_MEMORY_MS, + ); + this.lastSyncedAt = startedAt; + if (loaded > 0) { + this.logger.log(`최근 1시간 안에 끊은 세션 ${loaded}개를 불러왔습니다.`); } - if (rows.length > 0) { - this.logger.log( - `최근 1시간 안에 끊은 세션 ${rows.length}개를 불러왔습니다.`, - ); + } + + /** + * DB 에서 새로 끊긴 세션을 읽어 더한다 (13단계 — 15초마다) → 읽은 수. 실패해도 던지지 않는다(warn 한 번 — 다음 주기에 다시). + * 창: 지난 동기화를 시작한 때 - SYNC_OVERLAP_MS 부터(1시간보다 앞이면 1시간 전부터) + */ + @Interval(REVOKED_SESSION_SYNC_INTERVAL_MS) + async syncFromDatabase(now: number = Date.now()): Promise { + if (this.syncing) return 0; + this.syncing = true; + try { + const memoryStart = now - REVOKED_SESSION_MEMORY_MS; + const since = + this.lastSyncedAt === null + ? memoryStart + : Math.max(this.lastSyncedAt - SYNC_OVERLAP_MS, memoryStart); + const loaded = await this.loadRevokedSince(since); + this.lastSyncedAt = now; + if (this.syncFailureReported) { + this.syncFailureReported = false; + this.logger.log('끊은 세션 동기화가 다시 됩니다.'); + } + return loaded; + } catch (error) { + if (!this.syncFailureReported) { + this.syncFailureReported = true; + this.logger.warn( + `[SYS_001] 끊은 세션을 DB 에서 읽지 못했습니다 — 다음 주기에 다시 합니다(이 서버가 끊은 세션은 그대로 막힌다): ${errorMessage(error)}`, + ); + } + return 0; + } finally { + this.syncing = false; } } @@ -81,6 +131,19 @@ export class RevokedSessions implements OnApplicationBootstrap { return this.revokedAt.size; } + /** `since` 뒤에 끊긴 세션을 읽어 기억한다 → 읽은 수 (끊은 차례로 — sweep 이 앞에서부터 본다) */ + private async loadRevokedSince(since: number): Promise { + const rows = await this.sessions.find({ + select: { id: true, revokedAt: true }, + where: { revokedAt: MoreThan(new Date(since)) }, + order: { revokedAt: 'ASC' }, + }); + for (const row of rows) { + if (row.revokedAt) this.remember(row.id, row.revokedAt.getTime()); + } + return rows.length; + } + private remember(id: string, at: number): void { // 늦게 끊은 시각이 이긴다 — 다시 넣으면 맨 뒤로 (sweep 이 앞에서부터 본다) const previous = this.revokedAt.get(id); diff --git a/backend/src/modules/contacts/contact-groups.controller.ts b/backend/src/modules/contacts/contact-groups.controller.ts index d22239a..2581a9c 100644 --- a/backend/src/modules/contacts/contact-groups.controller.ts +++ b/backend/src/modules/contacts/contact-groups.controller.ts @@ -16,6 +16,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -50,7 +51,7 @@ const NAME_RULES = `이름은 앞뒤 공백을 뺀 1~${MAX_CONTACT_GROUP_NAME_LE @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(CONTACT_GROUPS_ROUTE) export class ContactGroupsController { diff --git a/backend/src/modules/contacts/contact-restorations.controller.ts b/backend/src/modules/contacts/contact-restorations.controller.ts index f056512..c7cd164 100644 --- a/backend/src/modules/contacts/contact-restorations.controller.ts +++ b/backend/src/modules/contacts/contact-restorations.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -26,7 +27,7 @@ import { @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(CONTACT_RESTORATIONS_ROUTE) export class ContactRestorationsController { constructor(private readonly contacts: ContactsService) {} diff --git a/backend/src/modules/contacts/contacts.controller.ts b/backend/src/modules/contacts/contacts.controller.ts index b4a5d6a..a145c8a 100644 --- a/backend/src/modules/contacts/contacts.controller.ts +++ b/backend/src/modules/contacts/contacts.controller.ts @@ -14,6 +14,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -57,7 +58,7 @@ const SIZE_RULE = @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(CONTACTS_ROUTE) export class ContactsController { diff --git a/backend/src/modules/contacts/directory-entries.controller.ts b/backend/src/modules/contacts/directory-entries.controller.ts index d419d67..59aca74 100644 --- a/backend/src/modules/contacts/directory-entries.controller.ts +++ b/backend/src/modules/contacts/directory-entries.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -38,7 +39,7 @@ const UNREADABLE = @ApiTags('Contacts') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(DIRECTORY_ENTRIES_ROUTE) export class DirectoryEntriesController { constructor( diff --git a/backend/src/modules/file-sharing/file-sharing.repository.ts b/backend/src/modules/file-sharing/file-sharing.repository.ts index 67c08b2..dd04ee4 100644 --- a/backend/src/modules/file-sharing/file-sharing.repository.ts +++ b/backend/src/modules/file-sharing/file-sharing.repository.ts @@ -127,6 +127,27 @@ export class FileSharingRepository { return (result.affected ?? 0) > 0; } + /** 그 직원의 살아 있는 링크(끊지 않았고 · 취소하지 않았고 · 기간 안) id — 운영 도구의 잠금 (13단계) */ + async aliveIdsOf(owner: string, now: Date): Promise { + const rows = await this.links + .createQueryBuilder('link') + .select('link.id', 'id') + .where('link.owner_email = :owner', { owner }) + .andWhere('link.revoked_at IS NULL') + .andWhere('link.canceled_at IS NULL') + .andWhere('link.expires_at >= :now', { now }) + .getRawMany<{ id: string }>(); + return rows.map((row) => row.id); + } + + /** 링크 하나와 파일 (주인과 상관없이) — 운영 도구의 링크 끊기 (13단계) */ + async findById(id: string): Promise { + const link = await this.links.findOneBy({ id }); + if (!link) return null; + const [record] = await this.withFiles([link]); + return record; + } + /** * 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다). * @returns 더했으면 true, 상한에 닿았으면 false diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts index 947aaf6..e0af247 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -347,6 +347,57 @@ export class FileSharingService implements BeforeApplicationShutdown { return this.summarize(record, await this.fileStatuses([record])); } + // ── 운영 도구 (13단계 — 계약 6절 "운영 도구") ───────────────────────────────────── + + /** + * 신고된 공개 링크 주소로 링크를 찾는다 (운영 도구의 「링크 끊기」) — 우리 공개 화면 주소가 아니거나 없는 링크 · 취소한 링크면 null. + * 주소 뒤의 쿼리 · 조각은 무시한다(받는 쪽 장비가 붙였을 수 있다) + */ + async findForOperator(url: string): Promise { + const token = tokenFromShareUrl(this.publicOrigin, url, { + allowQuery: true, + }); + if (token === null) return null; + const [record] = await this.repository.findByTokenHashes([ + hashShareToken(token), + ]); + return record && record.link.canceledAt === null ? record : null; + } + + /** + * 운영자가 링크를 끊는다 (`revoked_by = operator`) — 이번에 끊었으면 true (이미 끊었거나 기간이 지났으면 false). + * 파일의 보관 기간을 다시 맞춘다 (보낸 사람이 끊을 때와 같다) + */ + async revokeAsOperator(linkId: string): Promise { + const record = await this.repository.findById(linkId); + if (!record || record.link.canceledAt !== null) return false; + const revoked = await this.repository.revoke( + linkId, + 'operator', + new Date(), + ); + if (revoked) { + await this.refreshRetention(record.files.map((file) => file.fileId)); + this.logger.log( + `운영자가 대용량 첨부 링크를 끊었습니다: user=${maskEmail(record.link.ownerEmail)}, link=${linkId}, 파일 ${record.files.length}개`, + ); + } + return revoked; + } + + /** 그 직원의 살아 있는 링크를 모두 운영자 끊기로 → 끊은 수 (운영 도구의 잠금 — 묻고 한다) */ + async revokeAllOfOwnerAsOperator(owner: string): Promise { + const ids = await this.repository.aliveIdsOf( + owner.toLowerCase(), + new Date(), + ); + let revoked = 0; + for (const id of ids) { + if (await this.revokeAsOperator(id)) revoked += 1; + } + return revoked; + } + // ── 인용 속 링크의 상태 (POST /api/share-link-lookups) ───────────────────────── /** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */ diff --git a/backend/src/modules/file-sharing/share-link-lookup.controller.ts b/backend/src/modules/file-sharing/share-link-lookup.controller.ts index 6015834..d9b6c82 100644 --- a/backend/src/modules/file-sharing/share-link-lookup.controller.ts +++ b/backend/src/modules/file-sharing/share-link-lookup.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { NoStore } from '@/common/decorators/no-store.decorator'; @@ -31,7 +32,7 @@ export const SHARE_LINK_LOOKUPS_ROUTE = 'share-link-lookups'; @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(SHARE_LINK_LOOKUPS_ROUTE) export class ShareLinkLookupController { constructor(private readonly sharing: FileSharingService) {} diff --git a/backend/src/modules/file-storage/file-scan.service.spec.ts b/backend/src/modules/file-storage/file-scan.service.spec.ts index d2f2bfd..39de709 100644 --- a/backend/src/modules/file-storage/file-scan.service.spec.ts +++ b/backend/src/modules/file-storage/file-scan.service.spec.ts @@ -90,6 +90,16 @@ class FakeRepository { return Promise.resolve(true); } + /** 실제 저장소와 같은 조건 — ready 이고 아직 위험으로 적히지 않았을 때만 */ + markInfected(id: string, changes: Partial) { + const row = this.rows.get(id); + if (!row || row.state !== 'ready' || row.scanStatus === 'infected') { + return Promise.resolve(false); + } + Object.assign(row, changes, { scanStatus: 'infected' }); + return Promise.resolve(true); + } + update(id: string, changes: Partial) { const row = this.rows.get(id); if (row) Object.assign(row, changes); @@ -305,6 +315,8 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => { fileId: 'bad', name: 'bad.zip', scanStatus: 'infected', + size: repository.rows.get('bad')?.size, + uploadedAt: expect.any(Date) as Date, }, ]); expect(warn).toHaveBeenCalledWith( @@ -312,6 +324,23 @@ describe('FileScanService — 악성 검사 차례 (11단계)', () => { ); }); + it('같은 파일의 검사가 겹쳐 다른 쪽이 먼저 위험으로 적었으면 알리지 않는다 — 알림 메일은 파일마다 한 번 (리뷰 L1)', async () => { + repository.rows.set('bad', file('bad')); + verdicts.set('bad', { kind: 'infected', signature: 'X' }); + // 이 검사가 끝나기 전에 다른 검사(다른 인스턴스)가 먼저 위험으로 적었다 + blobs.openRead.mockImplementation((id: string) => { + const row = repository.rows.get(id); + if (row) row.scanStatus = 'infected'; + return Promise.resolve(Readable.from([Buffer.from(id)])); + }); + const events: FileScanVerdictEvent[] = []; + service.onVerdict((event) => events.push(event)); + + await run(); + expect(repository.rows.get('bad')?.scanStatus).toBe('infected'); + expect(events).toEqual([]); + }); + it('검사기에 닿지 못하면 검사 중 그대로 두고 1분부터 늘려 다시 — 이어서 실패하면 error (닫힌 쪽)', async () => { repository.rows.set('x', file('x')); verdicts.set('x', new ClamavUnavailableError('connect ECONNREFUSED')); diff --git a/backend/src/modules/file-storage/file-scan.service.ts b/backend/src/modules/file-storage/file-scan.service.ts index 229dce3..413fa95 100644 --- a/backend/src/modules/file-storage/file-scan.service.ts +++ b/backend/src/modules/file-storage/file-scan.service.ts @@ -226,8 +226,8 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown { scanStatus: 'infected' as const, scannedAt: now, }; - const changed = await this.repository.transition(file.id, ['ready'], { - scanStatus: 'infected', + // 처음 위험으로 적을 때만 changed — 검사가 겹쳐도 알림은 파일마다 한 번 (scan_status IS DISTINCT FROM 'infected') + const changed = await this.repository.markInfected(file.id, { scanSignature: verdict.signature, scannedAt: now, scanNextAt: null, @@ -247,11 +247,14 @@ export class FileScanService implements OnModuleInit, OnApplicationShutdown { ); } if (!changed) return; + // 위험 판정을 처음 적었을 때만 여기까지 온다(changed) — 받은편지함 알림 메일도 파일마다 한 번이다 (13단계) const event: FileScanVerdictEvent = { ownerEmail: file.ownerEmail, fileId: file.id, name: file.name, scanStatus: 'infected', + size: file.size, + uploadedAt: file.completedAt ?? file.createdAt, }; for (const listener of this.listeners) { try { diff --git a/backend/src/modules/file-storage/file-storage.repository.ts b/backend/src/modules/file-storage/file-storage.repository.ts index bc4d4ec..348d500 100644 --- a/backend/src/modules/file-storage/file-storage.repository.ts +++ b/backend/src/modules/file-storage/file-storage.repository.ts @@ -182,6 +182,26 @@ export class FileStorageRepository { return (result.affected ?? 0) > 0; } + /** + * 위험 판정을 적는다 — 아직 위험으로 적히지 않았고 올리기를 끝낸(ready) 파일일 때만 (조건부 UPDATE 한 번). + * 같은 파일의 검사가 겹쳐도(인스턴스 둘 · 다시 검사) 처음 적은 쪽만 true — 받은편지함 알림 메일은 파일마다 한 번이다 (13단계) + * @returns 적었으면 true + */ + async markInfected( + id: string, + changes: Partial, + ): Promise { + const result = await this.files + .createQueryBuilder() + .update(StoredFileEntity) + .set({ ...changes, scanStatus: 'infected' }) + .where('id = :id', { id }) + .andWhere(`state = 'ready'`) + .andWhere(`scan_status IS DISTINCT FROM 'infected'`) + .execute(); + return (result.affected ?? 0) > 0; + } + /** * 기간이 지난 파일을 지움(기간 지남)으로 — 그 순간에도 기간이 지났을 때만(그사이 임시 저장 · 링크로 늘었으면 그대로 둔다). * @returns 바꿨으면 true diff --git a/backend/src/modules/file-storage/file-storage.types.ts b/backend/src/modules/file-storage/file-storage.types.ts index dd4db3e..36d994c 100644 --- a/backend/src/modules/file-storage/file-storage.types.ts +++ b/backend/src/modules/file-storage/file-storage.types.ts @@ -110,6 +110,10 @@ export interface FileScanVerdictEvent { readonly fileId: string; readonly name: string; readonly scanStatus: ScanStatus; + /** 파일 크기 (바이트) — 받은편지함 알림 메일의 「크기」 (13단계) */ + readonly size: number; + /** 올린 때(다 올린 때 — 없으면 만들기 시작한 때) — 받은편지함 알림 메일의 「올린 때」 (13단계) */ + readonly uploadedAt: Date; } /** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */ diff --git a/backend/src/modules/file-storage/file-upload.controller.ts b/backend/src/modules/file-storage/file-upload.controller.ts index 635850c..900798c 100644 --- a/backend/src/modules/file-storage/file-upload.controller.ts +++ b/backend/src/modules/file-storage/file-upload.controller.ts @@ -27,6 +27,7 @@ import type { Request } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -69,7 +70,7 @@ function header(req: Request, name: string): string | undefined { @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(FILE_UPLOADS_ROUTE) export class FileUploadController { constructor(private readonly files: FileStorageService) {} @@ -252,7 +253,7 @@ export class FileUploadController { @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(FILE_QUOTA_ROUTE) export class FileQuotaController { constructor(private readonly files: FileStorageService) {} diff --git a/backend/src/modules/mail-signature/mail-signature.controller.ts b/backend/src/modules/mail-signature/mail-signature.controller.ts index a9d7be5..d2be4e8 100644 --- a/backend/src/modules/mail-signature/mail-signature.controller.ts +++ b/backend/src/modules/mail-signature/mail-signature.controller.ts @@ -26,6 +26,7 @@ import type { Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -60,7 +61,7 @@ const IMAGE_CONTENT_TYPES = ['image/png', 'image/jpeg', 'image/gif']; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('mail-signature') export class MailSignatureController { constructor(private readonly signatures: MailSignatureService) {} diff --git a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts index b6ea0fa..98dc12c 100644 --- a/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts +++ b/backend/src/modules/mail-user-settings/mail-user-settings.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -23,7 +24,7 @@ export const MAIL_USER_SETTINGS_ROUTE = 'mail-settings'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_USER_SETTINGS_ROUTE) export class MailUserSettingsController { constructor(private readonly settings: MailUserSettingsService) {} diff --git a/backend/src/modules/mail/draft.controller.ts b/backend/src/modules/mail/draft.controller.ts index fd1cbc4..334c17a 100644 --- a/backend/src/modules/mail/draft.controller.ts +++ b/backend/src/modules/mail/draft.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -36,8 +37,7 @@ const SAVE_BAD_REQUEST = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller(DRAFTS_ROUTE) diff --git a/backend/src/modules/mail/images/mail-image.controller.ts b/backend/src/modules/mail/images/mail-image.controller.ts index ff4d83e..74ae599 100644 --- a/backend/src/modules/mail/images/mail-image.controller.ts +++ b/backend/src/modules/mail/images/mail-image.controller.ts @@ -17,7 +17,10 @@ import { import { Throttle } from '@nestjs/throttler'; import type { Request, Response } from 'express'; -import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + ApiErrorResponse, + ApiSessionErrors, +} from '@/common/decorators/api-standard-response.decorator'; import { CurrentUser, type AuthenticatedUser, @@ -68,7 +71,7 @@ export function releaseWhenDone(res: Response, release: () => void): void { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_IMAGES_ROUTE) export class MailImageController { constructor(private readonly proxy: MailImageProxyService) {} diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts index 5efb4f3..cf213ac 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.spec.ts @@ -7949,6 +7949,19 @@ describe('JmapMailProvider', () => { }, }); }); + + it('첫 메서드(제출 찾기)가 막히면 그 오류로 — 뒤 메서드의 invalidResultReference(엔진 장애 503)로 보이지 않는다 (실사용 시험)', async () => { + const { provider } = fakeClient([ + [ + ['error', { type: 'forbidden' }, 'pq'], + ['error', { type: 'invalidResultReference' }, 'pg'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + ]); + await expect(provider.listScheduledMessages(ARA)).rejects.toMatchObject( + { code: 'AUTH_003' }, + ); + }); }); describe('붙잡은 발송 취소 (cancelPendingSend)', () => { diff --git a/backend/src/modules/mail/jmap/jmap-mail.provider.ts b/backend/src/modules/mail/jmap/jmap-mail.provider.ts index 601ed11..bbfbfac 100644 --- a/backend/src/modules/mail/jmap/jmap-mail.provider.ts +++ b/backend/src/modules/mail/jmap/jmap-mail.provider.ts @@ -852,6 +852,11 @@ export class JmapMailProvider implements MailProvider { const responses = await this.jmap.call(user, (accountId) => [ ['Identity/get', { accountId, ids: null }, 'i'], ]); + const identities = findResult(responses, 'i', 'Identity/get'); + if (!identities.ok && identities.error.type === 'forbidden') { + // 세션은 열렸는데 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면 그렇게 알린다(13단계, 아니면 아래가 지금처럼 던진다) + await this.jmap.throwIfPasswordExpiredFor(session.username); + } const { list = [] } = expectResult>( responses, 'i', @@ -2589,6 +2594,9 @@ export class JmapMailProvider implements MailProvider { 'g', ], ]); + // 첫 메서드(제출 찾기)의 오류부터 본다 — 막혔으면(forbidden) 뒤 메서드는 그 결과를 참조하지 못해 invalidResultReference 로 끝난다. + // 뒤의 것을 알리면 권한 문제가 엔진 장애(503 · ERROR)로 보였다 (실사용 시험 — 재설정 직후 15초) + expectResult(responses, `${HELD_CALLS}q`, 'EmailSubmission/query'); const { list: submissions = [] } = expectResult< JmapGetResult >(responses, `${HELD_CALLS}g`, 'EmailSubmission/get'); diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts new file mode 100644 index 0000000..f860ec3 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.spec.ts @@ -0,0 +1,646 @@ +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { + MailFilterSet, + MailVacationState, +} from '@/modules/mail/mail.types'; +import { + LOCK_FORWARD_RULE_NAME, + OpsEngineError, +} from '@/modules/ops/ops-engine.port'; +import type { JmapAccountSecurityProvider } from './jmap-account-security.provider'; +import type { JmapFilterScriptStore } from './jmap-filter-script'; +import { JmapOpsEngine, permissionsWithLogin } from './jmap-ops-engine'; + +const DOMAIN = 'arasystem.test'; +const ADMIN = `admin@${DOMAIN}`; +const ARA = `ara.kim@${DOMAIN}`; + +const settings: MailSettings = { + jmapBaseUrl: 'http://mail:8080', + domain: DOMAIN, + gatewayUser: `mail-gateway@${DOMAIN}`, + gatewayPassword: 'gateway-secret', + attachmentMaxBytes: 1, + attachmentTotalMaxBytes: 1, + blockedAccounts: new Set([ADMIN, `mail-directory@${DOMAIN}`]), + directoryAccount: `mail-directory@${DOMAIN}`, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +const adminSession = { + username: ADMIN, + apiUrl: 'https://mail.example/jmap/', + primaryAccounts: { 'urn:stalwart:jmap': 'adm' }, +}; + +/** 계정 하나 (관리 API 의 모양) */ +const araAccount = { + id: 'acc1', + name: 'ara.kim', + emailAddress: ARA, + description: '김아라', + permissions: { '@type': 'Inherit' }, + credentials: { + '0': { + '@type': 'Password', + secret: '****', + otpAuth: '****', + expiresAt: null, + }, + '1': { '@type': 'AppPassword', secret: '****' }, + }, + quotas: { maxDiskQuota: 1073741824 }, + usedDiskQuota: 2048, +}; + +describe('permissionsWithLogin — 로그인 권한 빼기 · 되돌리기 (13단계)', () => { + it('상속(Inherit)이면 Merge 로 authenticate 만 뺀다 · 되돌리면 다시 상속', () => { + const locked = permissionsWithLogin({ '@type': 'Inherit' }, false); + expect(locked).toEqual({ + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions: { authenticate: true }, + }); + expect(permissionsWithLogin(locked as never, true)).toEqual({ + '@type': 'Inherit', + }); + }); + + it('Merge 에 다른 권한이 있으면 그것은 지킨다', () => { + const current = { + '@type': 'Merge', + enabledPermissions: { emailSend: true }, + disabledPermissions: { jmapPrincipalQuery: true, authenticate: true }, + }; + expect(permissionsWithLogin(current, true)).toEqual({ + '@type': 'Merge', + enabledPermissions: { emailSend: true }, + disabledPermissions: { jmapPrincipalQuery: true }, + }); + }); + + it('이미 그 상태면 unchanged · 직접 정한 권한(Replace)은 바꾸지 않는다', () => { + expect(permissionsWithLogin({ '@type': 'Inherit' }, true)).toBe( + 'unchanged', + ); + expect(() => + permissionsWithLogin({ '@type': 'Replace', permissions: {} }, false), + ).toThrow(OpsEngineError); + }); +}); + +describe('JmapOpsEngine — 관리 API · 대리 접근 (13단계)', () => { + let fetchMock: jest.SpyInstance; + let accountSecurity: { deleteAllAppPasswords: jest.Mock }; + let filterScripts: { + getFilters: jest.Mock; + setFilters: jest.Mock; + getVacation: jest.Mock; + setVacation: jest.Mock; + }; + let engine: JmapOpsEngine; + + /** n 번째 관리 API 호출의 본문 */ + const bodyAt = (index: number) => + JSON.parse( + (fetchMock.mock.calls[index] as [string, RequestInit])[1].body as string, + ) as { methodCalls: [string, Record, string][] }; + + const signedIn = async () => { + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['x:Account/get', { list: [] }, 'p']] }), + ); + await expect(engine.signIn('admin-secret')).resolves.toBe('ok'); + }; + + beforeEach(() => { + fetchMock = jest.spyOn(global, 'fetch'); + accountSecurity = { deleteAllAppPasswords: jest.fn().mockResolvedValue(3) }; + const filters: MailFilterSet = { rules: [], version: 'v1', isActive: true }; + const vacation: MailVacationState = { + isEnabled: false, + fromDate: null, + toDate: null, + subject: '', + textBody: '', + isActive: true, + }; + filterScripts = { + getFilters: jest.fn().mockResolvedValue(filters), + setFilters: jest.fn().mockResolvedValue(filters), + getVacation: jest.fn().mockResolvedValue(vacation), + setVacation: jest.fn().mockResolvedValue(vacation), + }; + engine = new JmapOpsEngine( + settings, + ADMIN, + accountSecurity as unknown as JmapAccountSecurityProvider, + filterScripts as unknown as JmapFilterScriptStore, + ); + }); + + afterEach(() => fetchMock.mockRestore()); + + it('관리자 로그인 — 401 은 rejected, 402 는 two-factor, 관리 권한이 없으면 not-admin', async () => { + fetchMock.mockResolvedValueOnce(json({}, 401)); + await expect(engine.signIn('x')).resolves.toBe('rejected'); + fetchMock.mockResolvedValueOnce(json({}, 402)); + await expect(engine.signIn('x')).resolves.toBe('two-factor'); + fetchMock + .mockResolvedValueOnce(json(adminSession)) + .mockResolvedValueOnce( + json({ methodResponses: [['error', { type: 'forbidden' }, 'p']] }), + ); + await expect(engine.signIn('x')).resolves.toBe('not-admin'); + // 관리자 Basic 은 관리자 주소로 · 엔진 안 주소(MAIL_JMAP_URL)에 경로만 붙인다 + const [url, init] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(url).toBe('http://mail:8080/jmap/session'); + const authorization = (init.headers as Record) + .authorization; + expect(Buffer.from(authorization.slice(6), 'base64').toString()).toBe( + `${ADMIN}:x`, + ); + expect((fetchMock.mock.calls[3] as [string])[0]).toBe( + 'http://mail:8080/jmap/', + ); + }); + + it('로그인하지 않았으면 관리 API 를 부르지 않는다', async () => { + await expect(engine.findEmployee(ARA)).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + }); + + it('직원 찾기 — 잠금 · 2단계 · 앱 비밀번호 수 · 용량을 읽는다, 관리자 주소는 직원이 아니다', async () => { + await signedIn(); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/query', { ids: ['acc1'] }, 'q']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ); + await expect(engine.findEmployee(ARA)).resolves.toEqual({ + id: 'acc1', + email: ARA, + name: '김아라', + locked: false, + customPermissions: false, + passwordExpired: false, + twoFactorEnabled: true, + appPasswords: 1, + usedBytes: 2048, + quotaBytes: 1073741824, + }); + expect(bodyAt(2).methodCalls[0][1]).toMatchObject({ + filter: { name: 'ara.kim' }, + }); + await expect(engine.findEmployee(ADMIN)).resolves.toBeNull(); + }); + + it('입사 — 직원 역할 · 임시 비밀번호 · 지난 만료 · 용량을 한 번에 만든다', async () => { + await signedIn(); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Domain/query', { ids: ['d1'] }, 'q'], + ['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { created: { a: { id: 'acc9' } } }, 's'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/get', + { + list: [ + { + ...araAccount, + id: 'acc9', + emailAddress: `hong@${DOMAIN}`, + credentials: { + '0': { + '@type': 'Password', + expiresAt: '2020-01-01T00:00:00Z', + }, + }, + }, + ], + }, + 'g', + ], + ], + }), + ); + const created = await engine.createEmployee({ + localPart: 'hong', + name: '홍길동', + quotaBytes: 5, + tempPassword: 'Temp1234Temp1234', + }); + expect(created).toMatchObject({ id: 'acc9', passwordExpired: true }); + const create = bodyAt(3).methodCalls[0][1].create as Record< + string, + Record + >; + expect(create.a).toMatchObject({ + '@type': 'User', + name: 'hong', + domainId: 'd1', + description: '홍길동', + roles: { '@type': 'User' }, + quotas: { maxDiskQuota: 5 }, + }); + const credential = ( + create.a.credentials as Record> + )['0']; + expect(credential.secret).toBe('Temp1234Temp1234'); + expect(Date.parse(credential.expiresAt as string)).toBeLessThan(Date.now()); + }); + + it('약한 임시 비밀번호(invalidProperties secret)는 weak-password, 같은 이름은 exists', async () => { + await signedIn(); + const domain = json({ + methodResponses: [ + ['x:Domain/query', { ids: ['d1'] }, 'q'], + ['x:Domain/get', { list: [{ id: 'd1', name: DOMAIN }] }, 'g'], + ], + }); + fetchMock.mockResolvedValueOnce(domain).mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/set', + { + notCreated: { + a: { type: 'invalidProperties', properties: ['secret'] }, + }, + }, + 's', + ], + ], + }), + ); + await expect( + engine.createEmployee({ + localPart: 'hong', + name: '홍', + quotaBytes: null, + tempPassword: 'weak', + }), + ).rejects.toMatchObject({ kind: 'weak-password' }); + + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + [ + 'x:Account/set', + { notCreated: { a: { type: 'primaryKeyViolation' } } }, + 's', + ], + ], + }), + ); + await expect( + engine.createEmployee({ + localPart: 'hong', + name: '홍', + quotaBytes: null, + tempPassword: 'x', + }), + ).rejects.toMatchObject({ kind: 'exists' }); + }); + + it('재설정 · 2단계 해제 · 잠금은 비밀번호 자격의 칸 · 권한 칸을 패치한다', async () => { + await signedIn(); + const account = json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }); + const updated = json({ + methodResponses: [['x:Account/set', { updated: { acc1: null } }, 's']], + }); + fetchMock + .mockResolvedValueOnce(account) + .mockResolvedValueOnce(updated) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { updated: { acc1: null } }, 's'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [araAccount] }, 'g']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/set', { updated: { acc1: null } }, 's'], + ], + }), + ); + + await engine.resetPassword('acc1', 'NewTemp1234NewTe'); + const reset = ( + bodyAt(3).methodCalls[0][1].update as Record< + string, + Record + > + ).acc1; + expect(reset['credentials/0/secret']).toBe('NewTemp1234NewTe'); + expect(Date.parse(reset['credentials/0/expiresAt'] as string)).toBeLessThan( + Date.now(), + ); + // 2단계 인증(OTP)은 칸에 넣지 않는다 — 관리자의 비밀번호 바꾸기는 OTP 를 남긴다(엔진 확인) + expect(reset).not.toHaveProperty('credentials/0/otpAuth'); + + await engine.removeTwoFactor('acc1'); + expect( + (bodyAt(5).methodCalls[0][1].update as Record).acc1, + ).toEqual({ 'credentials/0/otpAuth': null }); + + await engine.setLoginEnabled('acc1', false); + expect( + (bodyAt(7).methodCalls[0][1].update as Record).acc1, + ).toEqual({ + permissions: { + '@type': 'Merge', + enabledPermissions: {}, + disabledPermissions: { authenticate: true }, + }, + }); + }); + + it('주소만으로 직원인지 — 관리자 · 게이트웨이 · 사내 목록 · 다른 도메인은 아니다 (엔진에 묻지 않는다)', () => { + expect(engine.isEmployeeAddress(ARA)).toBe(true); + expect(engine.isEmployeeAddress(` ${ARA.toUpperCase()} `)).toBe(true); + for (const address of [ + ADMIN, + `mail-gateway@${DOMAIN}`, + `mail-directory@${DOMAIN}`, + 'ara.kim@other.test', + ]) { + expect(engine.isEmployeeAddress(address)).toBe(false); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('계정 종류(@type)를 읽고 User 만 직원이다 — Group(공유 메일함)은 현황 · 명령에서 뺀다', async () => { + await signedIn(); + const teamAccount = { + ...araAccount, + id: 'grp1', + '@type': 'Group', + name: 'team', + emailAddress: `team@${DOMAIN}`, + }; + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [ + ['x:Account/query', { ids: ['acc1', 'grp1'] }, 'q'], + [ + 'x:Account/get', + { list: [{ ...araAccount, '@type': 'User' }, teamAccount] }, + 'g', + ], + ], + }), + ); + const employees = await engine.listEmployees(); + expect(employees.map((employee) => employee.email)).toEqual([ARA]); + // 칸을 적어야 돌려준다 — @type 을 함께 읽는다 + expect(bodyAt(2).methodCalls[1][1].properties).toContain('@type'); + + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/query', { ids: ['grp1'] }, 'q']], + }), + ) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/get', { list: [teamAccount] }, 'g']], + }), + ); + await expect(engine.findEmployee(`team@${DOMAIN}`)).resolves.toBeNull(); + }); + + it('메서드의 forbidden 은 그 일의 거절(refused)이다 — 관리자 로그인이 끊긴 것은 HTTP 401 · 403 뿐', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [['error', { type: 'forbidden' }, 's']], + }), + ); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'refused', + detail: 'forbidden', + }); + // 로그인은 그대로다 — 다음 호출도 보낸다 + fetchMock.mockResolvedValueOnce( + json({ + methodResponses: [['x:Account/set', { destroyed: ['acc1'] }, 's']], + }), + ); + await expect(engine.deleteAccount('acc1')).resolves.toBeUndefined(); + + fetchMock.mockResolvedValueOnce(json({}, 403)); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + }); + + it('머리글 뒤 본문이 멈추면 제한 시간에 끊는다(timeout) — 본문 읽기도 같은 시간 안', async () => { + jest.useFakeTimers(); + try { + await signedIn(); + // 머리글은 곧 오고 본문은 오지 않는 응답 — 신호가 끊기면 읽기도 끊긴다 + fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { + const signal = init.signal as AbortSignal; + const body = new ReadableStream({ + start(controller) { + signal.addEventListener('abort', () => + controller.error(new DOMException('aborted', 'AbortError')), + ); + }, + }); + return Promise.resolve( + new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + }); + const pending = engine.deleteAccount('acc1'); + const settled = expect(pending).rejects.toMatchObject({ + kind: 'unavailable', + detail: 'timeout', + }); + await jest.advanceTimersByTimeAsync(15_000); + await settled; + } finally { + jest.useRealTimers(); + } + }); + + it('본문이 JSON 이 아니면 malformed-response', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce( + new Response('oops', { status: 200 }), + ); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'unavailable', + detail: 'malformed-response', + }); + }); + + it('관리자 로그인이 그사이 끊기면(401) not-signed-in — 다시 로그인할 때까지 부르지 않는다', async () => { + await signedIn(); + fetchMock.mockResolvedValueOnce(json({}, 401)); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + await expect(engine.deleteAccount('acc1')).rejects.toMatchObject({ + kind: 'not-signed-in', + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('잠금 Sieve — 넘기기 규칙을 맨 앞에(모든 메일 · 사본) · 자동 응답을 켠다, 떼기는 그 규칙만 빼고 자동 응답을 끈다', async () => { + const userRule = { + id: 'r1', + name: '내 규칙', + isEnabled: true, + match: 'any' as const, + conditions: [ + { field: 'from' as const, operator: 'contains' as const, value: 'x' }, + ], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + forwardTo: null, + }, + }; + filterScripts.getFilters.mockResolvedValue({ + rules: [userRule], + version: 'v1', + isActive: true, + }); + await engine.applyLockRules(ARA, { + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: { subject: '부재 안내', body: '퇴사했습니다.' }, + }); + const saved = (filterScripts.setFilters.mock.calls[0] as unknown[])[1] as { + rules: { + name: string; + match: string; + actions: { forwardTo: string | null }; + }[]; + version: string; + }; + expect(saved.version).toBe('v1'); + expect(saved.rules.map((rule) => rule.name)).toEqual([ + LOCK_FORWARD_RULE_NAME, + '내 규칙', + ]); + expect(saved.rules[0]).toMatchObject({ + match: 'always', + actions: { forwardTo: `doyun.lee@${DOMAIN}` }, + }); + expect(filterScripts.setVacation).toHaveBeenCalledWith(ARA, { + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재 안내', + textBody: '퇴사했습니다.', + }); + + filterScripts.getFilters.mockResolvedValue({ + rules: saved.rules, + version: 'v2', + isActive: true, + }); + filterScripts.getVacation.mockResolvedValue({ + isEnabled: true, + fromDate: null, + toDate: null, + subject: '부재 안내', + textBody: '퇴사했습니다.', + isActive: true, + }); + await expect(engine.getLockRules(ARA)).resolves.toEqual({ + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: true, + }); + await engine.clearLockRules(ARA); + expect(filterScripts.setFilters).toHaveBeenLastCalledWith(ARA, { + rules: [saved.rules[1]], + version: 'v2', + }); + expect(filterScripts.setVacation).toHaveBeenLastCalledWith( + ARA, + expect.objectContaining({ isEnabled: false, subject: '부재 안내' }), + ); + }); + + it('필터가 이미 50개면 넘기기 규칙을 더하지 않는다 (too-many-rules)', async () => { + filterScripts.getFilters.mockResolvedValue({ + rules: Array.from({ length: 50 }, (_, index) => ({ + id: `r${index}`, + name: `규칙 ${index}`, + isEnabled: true, + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: true, + flag: false, + forwardTo: null, + }, + })), + version: 'v1', + isActive: true, + }); + await expect( + engine.applyLockRules(ARA, { + forwardTo: `doyun.lee@${DOMAIN}`, + autoReply: null, + }), + ).rejects.toMatchObject({ kind: 'too-many-rules' }); + expect(filterScripts.setFilters).not.toHaveBeenCalled(); + }); + + it('앱 비밀번호 지우기는 대리 접근 어댑터에 맡긴다', async () => { + await expect(engine.deleteAppPasswords(ARA)).resolves.toBe(3); + expect(accountSecurity.deleteAllAppPasswords).toHaveBeenCalledWith(ARA); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-ops-engine.ts b/backend/src/modules/mail/jmap/jmap-ops-engine.ts new file mode 100644 index 0000000..d1eeca5 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-ops-engine.ts @@ -0,0 +1,784 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { isEmployeeAddress } from '@/modules/auth/employee-address'; +import { MAX_MAIL_FILTER_RULES } from '@/modules/mail/mail.constants'; +import { + MAIL_SETTINGS, + isMailEngineConfigured, + type MailSettings, +} from '@/modules/mail/mail.settings'; +import type { MailFilterRule } from '@/modules/mail/mail.types'; +import { + LOCK_FORWARD_RULE_NAME, + OPS_ADMIN_ACCOUNT, + OpsEngineError, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type LockRulesState, + type OpsEngine, + type OpsSignInResult, +} from '@/modules/ops/ops-engine.port'; +import { JmapAccountSecurityProvider } from './jmap-account-security.provider'; +import { JmapFilterScriptStore } from './jmap-filter-script'; +import { basicAuthorization, toRelativeReference } from './jmap.client'; +import { fallbackDisplayName } from './jmap.mapper'; +import type { + JmapError, + JmapInvocation, + JmapResponseBody, + JmapSessionResource, +} from './jmap.types'; + +/** 관리 API(Stalwart `x:` 객체)의 기능 — 관리자 세션의 primaryAccounts 에서 이 이름으로 계정 id 를 찾는다 */ +const MANAGEMENT_CAPABILITY = 'urn:stalwart:jmap'; +const CORE_CAPABILITY = 'urn:ietf:params:jmap:core'; +const SESSION_PATH = '/jmap/session'; +/** 관리 API 한 번의 제한 시간 */ +const ADMIN_REQUEST_TIMEOUT_MS = 15_000; +/** 목록을 한 번에 읽는 상한 — 직원 · 공용 주소 수보다 넉넉하게 */ +const LIST_LIMIT = 1_000; +/** 계정에서 읽는 칸 — 현황 · 명령에 쓰는 것만 */ +const ACCOUNT_PROPERTIES = [ + // 계정 종류 — 칸을 적어야 돌려준다(2026-11-02 확인). User 만 직원이다(Group 은 공유 메일함) + '@type', + 'name', + 'emailAddress', + 'description', + 'permissions', + 'credentials', + 'quotas', + 'usedDiskQuota', +]; +/** 만료를 지난 날로 — "다음 로그인 때 바꾸기" (엔진이 그 계정의 권한을 비밀번호 바꾸기만으로 줄인다 — 2026-11-02 확인) */ +const EXPIRED_AT_OFFSET_MS = 60_000; + +const HTTP_UNAUTHORIZED = 401; +const HTTP_PAYMENT_REQUIRED = 402; +const HTTP_FORBIDDEN = 403; + +/** 엔진의 계정 (관리 API — 읽는 칸만) */ +interface JmapAccount { + readonly id?: string; + readonly '@type'?: unknown; + readonly name?: unknown; + readonly emailAddress?: unknown; + readonly description?: unknown; + readonly permissions?: JmapPermissions; + readonly credentials?: Record; + readonly quotas?: { readonly maxDiskQuota?: unknown } | null; + readonly usedDiskQuota?: unknown; +} + +interface JmapPermissions { + readonly '@type'?: unknown; + readonly enabledPermissions?: Record; + readonly disabledPermissions?: Record; + readonly permissions?: Record; +} + +interface JmapCredential { + readonly '@type'?: unknown; + readonly expiresAt?: unknown; + /** 켜져 있으면 "****"(가린 값), 꺼져 있으면 칸이 없다 */ + readonly otpAuth?: unknown; +} + +interface JmapMailingList { + readonly id?: string; + readonly name?: unknown; + readonly emailAddress?: unknown; + readonly description?: unknown; + readonly recipients?: Record | null; +} + +interface AdminSession { + readonly authorization: string; + readonly accountId: string; + readonly apiPath: string; +} + +interface SetResult { + readonly created?: Record; + readonly notCreated?: Record; + readonly updated?: Record; + readonly notUpdated?: Record; + readonly destroyed?: string[]; + readonly notDestroyed?: Record; +} + +interface QueryResult { + readonly ids?: string[]; +} + +interface GetResult { + readonly list?: T[]; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value !== '' ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** 엔진의 SetError → 도구의 까닭 (종류만 — 설명 문장은 넣지 않는다) */ +function refusal(error: JmapError | undefined): OpsEngineError { + const type = error?.type ?? 'unknown'; + if (type === 'invalidProperties' && error?.properties?.includes('secret')) { + return new OpsEngineError('weak-password', type); + } + if (type === 'primaryKeyViolation' || type === 'alreadyExists') { + return new OpsEngineError('exists', type); + } + if (type === 'notFound') return new OpsEngineError('not-found', type); + return new OpsEngineError('refused', type); +} + +/** 계정 권한 칸에서 로그인 권한이 빠졌는가 */ +function isLocked(permissions: JmapPermissions | undefined): boolean { + const type = permissions?.['@type']; + if (type === 'Merge') { + return permissions?.disabledPermissions?.authenticate === true; + } + if (type === 'Replace') + return permissions?.permissions?.authenticate !== true; + return false; +} + +/** 비밀번호 자격(@type Password)의 id — 계정을 만들 때 "0" 이다 */ +function passwordCredentialId(account: JmapAccount): string | null { + for (const [id, credential] of Object.entries(account.credentials ?? {})) { + if (credential?.['@type'] === 'Password') return id; + } + return null; +} + +/** + * 로그인 권한을 빼거나 되돌린 권한 칸 — 상속(Inherit)이면 Merge 로 빼고, Merge 면 그 안에서 바꾼다. + * 되돌린 뒤 Merge 에 남은 것이 없으면 상속으로 돌린다. 직접 정한 권한(Replace)은 바꾸지 않는다 + */ +export function permissionsWithLogin( + permissions: JmapPermissions | undefined, + enabled: boolean, +): JmapPermissions | 'unchanged' { + const type = permissions?.['@type'] ?? 'Inherit'; + if (type === 'Replace') { + throw new OpsEngineError('custom-permissions'); + } + const enabledPermissions: Record = {}; + const disabledPermissions: Record = {}; + if (type === 'Merge') { + for (const [name, on] of Object.entries( + permissions?.enabledPermissions ?? {}, + )) { + if (on === true) enabledPermissions[name] = true; + } + for (const [name, on] of Object.entries( + permissions?.disabledPermissions ?? {}, + )) { + if (on === true) disabledPermissions[name] = true; + } + } + const lockedNow = disabledPermissions.authenticate === true; + if (enabled ? !lockedNow : lockedNow) return 'unchanged'; + if (enabled) { + delete disabledPermissions.authenticate; + } else { + delete enabledPermissions.authenticate; + disabledPermissions.authenticate = true; + } + if ( + Object.keys(enabledPermissions).length === 0 && + Object.keys(disabledPermissions).length === 0 + ) { + return { '@type': 'Inherit' }; + } + return { '@type': 'Merge', enabledPermissions, disabledPermissions }; +} + +/** + * Stalwart 운영 어댑터 — OpsEngine 의 구현 (13단계 — 계약 6절 "운영 도구"). + * - 관리자 Basic: `x:Account` · `x:MailingList` · `x:Domain`(계정을 만들 도메인 id) 관리 API 에만 — 비밀번호는 이 객체의 메모리에만, 로그에 남기지 않는다 + * - 게이트웨이 대리 접근: 앱 비밀번호(JmapAccountSecurityProvider) · 잠금 Sieve(JmapFilterScriptStore — 직원의 필터 · 부재중과 한 스크립트. + * 엔진은 계정마다 스크립트 하나만 켠다 — 따로 스크립트를 켜면 직원의 필터 · 부재중 · 차단이 꺼진다, 2026-11-02 다시 확인) + * 엔진 동작(2026-11-02 개발 엔진 확인): 만들 때 만료를 함께 받는다 · 관리자의 비밀번호 바꾸기는 OTP 를 남긴다 · + * OTP 지우기는 비밀번호 자격의 otpAuth 를 null 로 · 잠금은 계정 권한의 Merge disabledPermissions.authenticate + */ +@Injectable() +export class JmapOpsEngine implements OpsEngine { + private admin: AdminSession | null = null; + private domainId: string | null = null; + + constructor( + @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + @Inject(OPS_ADMIN_ACCOUNT) private readonly adminAccount: string, + private readonly accountSecurity: JmapAccountSecurityProvider, + private readonly filterScripts: JmapFilterScriptStore, + ) {} + + async signIn(adminPassword: string): Promise { + const authorization = basicAuthorization(this.adminAccount, adminPassword); + const reply = await this.exchange( + SESSION_PATH, + { headers: { authorization } }, + async (response) => { + if ( + response.status === HTTP_UNAUTHORIZED || + response.status === HTTP_FORBIDDEN + ) { + await discard(response); + return 'rejected' as const; + } + if (response.status === HTTP_PAYMENT_REQUIRED) { + await discard(response); + return 'two-factor' as const; + } + if (!response.ok) { + await discard(response); + throw new OpsEngineError('unavailable', `http-${response.status}`); + } + return (await readJson(response)) as JmapSessionResource | null; + }, + ); + if (reply === 'rejected' || reply === 'two-factor') return reply; + const session = reply; + const accountId = session?.primaryAccounts?.[MANAGEMENT_CAPABILITY]; + if (!accountId || !session?.apiUrl) return 'not-admin'; + this.admin = { + authorization, + accountId, + apiPath: toRelativeReference(session.apiUrl), + }; + // 관리 권한 — 데이터를 읽지 않는 계정 목록 호출 하나 (직원이면 forbidden) + const [probe] = await this.call([ + ['x:Account/get', { accountId, ids: [], properties: ['name'] }, 'p'], + ]); + if (!probe || probe[0] === 'error') { + this.admin = null; + return 'not-admin'; + } + return 'ok'; + } + + isEmployeeAddress(email: string): boolean { + return this.isEmployee(email.trim().toLowerCase()); + } + + async findEmployee(email: string): Promise { + const address = email.trim().toLowerCase(); + if (!this.isEmployee(address)) return null; + const account = await this.findAccount(address); + return account ? this.toEmployee(account) : null; + } + + async listEmployees(): Promise { + const accounts = await this.getAll( + 'Account', + ACCOUNT_PROPERTIES, + ); + return accounts + .map((account) => this.toEmployee(account)) + .filter((employee): employee is EngineEmployee => employee !== null) + .filter((employee) => this.isEmployee(employee.email)) + .sort((a, b) => a.email.localeCompare(b.email)); + } + + async addressInUse(email: string): Promise { + const address = email.trim().toLowerCase(); + if (await this.findAccount(address)) return true; + const lists = await this.listMailingLists(); + return lists.some((list) => list.email === address); + } + + async createEmployee(input: { + readonly localPart: string; + readonly name: string; + readonly quotaBytes: number | null; + readonly tempPassword: string; + }): Promise { + const domainId = await this.requireDomainId(); + const result = await this.set('Account', { + create: { + a: { + '@type': 'User', + name: input.localPart, + domainId, + description: input.name, + roles: { '@type': 'User' }, + credentials: { + 0: { + '@type': 'Password', + secret: input.tempPassword, + expiresAt: this.expiredAt(), + }, + }, + ...(input.quotaBytes !== null + ? { quotas: { maxDiskQuota: input.quotaBytes } } + : {}), + }, + }, + }); + const failure = result.notCreated?.a; + if (failure) throw refusal(failure); + const id = result.created?.a?.id; + if (!id) throw new OpsEngineError('refused', 'no-created-id'); + const created = await this.getAccounts([id]); + const employee = created[0] ? this.toEmployee(created[0]) : null; + if (!employee) throw new OpsEngineError('refused', 'created-unreadable'); + return employee; + } + + async resetPassword(id: string, tempPassword: string): Promise { + const credentialId = await this.requirePasswordCredential(id); + await this.update('Account', id, { + [`credentials/${credentialId}/secret`]: tempPassword, + [`credentials/${credentialId}/expiresAt`]: this.expiredAt(), + }); + } + + async removeTwoFactor(id: string): Promise { + const credentialId = await this.requirePasswordCredential(id); + await this.update('Account', id, { + [`credentials/${credentialId}/otpAuth`]: null, + }); + } + + async setLoginEnabled(id: string, enabled: boolean): Promise { + const [account] = await this.getAccounts([id]); + if (!account) throw new OpsEngineError('not-found'); + const next = permissionsWithLogin(account.permissions, enabled); + if (next === 'unchanged') return; + await this.update('Account', id, { permissions: next }); + } + + async deleteAccount(id: string): Promise { + const result = await this.set('Account', { destroy: [id] }); + const failure = result.notDestroyed?.[id]; + if (failure && failure.type !== 'notFound') throw refusal(failure); + } + + async listMailingLists(): Promise { + const lists = await this.getAll('MailingList', null); + return lists + .map((list) => this.toMailingList(list)) + .filter((list): list is EngineMailingList => list !== null) + .sort((a, b) => a.email.localeCompare(b.email)); + } + + async createMailingList(input: { + readonly localPart: string; + readonly name: string; + readonly members: readonly string[]; + }): Promise { + const domainId = await this.requireDomainId(); + const result = await this.set('MailingList', { + create: { + l: { + '@type': 'MailingList', + name: input.localPart, + domainId, + description: input.name, + recipients: Object.fromEntries( + input.members.map((member) => [member, true]), + ), + }, + }, + }); + const failure = result.notCreated?.l; + if (failure) throw refusal(failure); + const id = result.created?.l?.id; + const created = (await this.listMailingLists()).find( + (list) => list.id === id, + ); + if (!created) throw new OpsEngineError('refused', 'created-unreadable'); + return created; + } + + async setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise { + await this.update('MailingList', id, { + [`recipients/${member}`]: included ? true : null, + }); + } + + // ── 게이트웨이 대리 접근 (직원의 앱 비밀번호 · Sieve) ────────────────────────── + + deleteAppPasswords(email: string): Promise { + return this.accountSecurity.deleteAllAppPasswords(email); + } + + async getLockRules(email: string): Promise { + const [filters, vacation] = await Promise.all([ + this.filterScripts.getFilters(email), + this.filterScripts.getVacation(email), + ]); + const rule = filters.rules.find(isLockRule); + return { + forwardTo: rule?.actions.forwardTo ?? null, + autoReply: vacation.isEnabled, + }; + } + + async applyLockRules(email: string, rules: LockRules): Promise { + if (rules.forwardTo !== null) { + const filters = await this.filterScripts.getFilters(email); + const others = filters.rules.filter((rule) => !isLockRule(rule)); + if (others.length + 1 > MAX_MAIL_FILTER_RULES) { + throw new OpsEngineError('too-many-rules'); + } + // 맨 앞에 — 엔진은 한 통에 리다이렉트 하나만 한다(뒤의 전달은 버린다). 사본은 그 직원의 받은편지함에도 남는다 + const lockRule: MailFilterRule = { + id: randomUUID(), + name: LOCK_FORWARD_RULE_NAME, + isEnabled: true, + match: 'always', + conditions: [], + actions: { + moveToMailboxId: null, + markAsRead: false, + flag: false, + forwardTo: rules.forwardTo, + }, + }; + await this.filterScripts.setFilters(email, { + rules: [lockRule, ...others], + version: filters.version, + }); + } + if (rules.autoReply !== null) { + await this.filterScripts.setVacation(email, { + isEnabled: true, + fromDate: null, + toDate: null, + subject: rules.autoReply.subject, + textBody: rules.autoReply.body, + }); + } + } + + async clearLockRules(email: string): Promise { + const filters = await this.filterScripts.getFilters(email); + if (filters.rules.some(isLockRule)) { + await this.filterScripts.setFilters(email, { + rules: filters.rules.filter((rule) => !isLockRule(rule)), + version: filters.version, + }); + } + const vacation = await this.filterScripts.getVacation(email); + if (vacation.isEnabled) { + await this.filterScripts.setVacation(email, { + isEnabled: false, + fromDate: vacation.fromDate, + toDate: vacation.toDate, + subject: vacation.subject, + textBody: vacation.textBody, + }); + } + } + + // ── 관리 API ─────────────────────────────────────────────────────────────── + + private isEmployee(address: string): boolean { + const { domain, gatewayUser, blockedAccounts } = this.settings; + return ( + domain !== null && + isEmployeeAddress(address, domain, gatewayUser, blockedAccounts) + ); + } + + private expiredAt(): string { + return new Date(Date.now() - EXPIRED_AT_OFFSET_MS) + .toISOString() + .replace(/\.\d+Z$/, 'Z'); + } + + private toEmployee(account: JmapAccount): EngineEmployee | null { + const id = account.id; + const email = asString(account.emailAddress)?.toLowerCase() ?? null; + if (!id || email === null) return null; + // 직원은 User 계정뿐이다 — Group(공유 메일함)은 현황 · 명령의 대상이 아니다. 칸이 없으면(옛 엔진) User 로 본다 + const type = asString(account['@type']); + if (type !== null && type !== 'User') return null; + const credentials = Object.values(account.credentials ?? {}); + const password = credentials.find( + (credential) => credential?.['@type'] === 'Password', + ); + const expiresAt = asString(password?.expiresAt); + const description = asString(account.description)?.trim(); + return { + id, + email, + name: description || fallbackDisplayName(email), + locked: isLocked(account.permissions), + customPermissions: account.permissions?.['@type'] === 'Replace', + passwordExpired: + expiresAt !== null && Date.parse(expiresAt) <= Date.now(), + twoFactorEnabled: + password?.otpAuth !== undefined && password.otpAuth !== null, + appPasswords: credentials.filter( + (credential) => credential?.['@type'] === 'AppPassword', + ).length, + usedBytes: asNumber(account.usedDiskQuota), + quotaBytes: asNumber(account.quotas?.maxDiskQuota), + }; + } + + private toMailingList(list: JmapMailingList): EngineMailingList | null { + const email = asString(list.emailAddress)?.toLowerCase() ?? null; + if (!list.id || email === null) return null; + return { + id: list.id, + email, + name: asString(list.description) ?? asString(list.name) ?? email, + members: Object.entries(list.recipients ?? {}) + .filter(([, on]) => on === true) + .map(([address]) => address.toLowerCase()) + .sort(), + }; + } + + /** 주소로 계정 하나 — 로컬 이름으로 거른 뒤 주소가 정확히 같은 것만 */ + private async findAccount(address: string): Promise { + const localPart = address.slice(0, address.indexOf('@')); + const [query] = await this.call([ + [ + 'x:Account/query', + { + accountId: this.session().accountId, + filter: { name: localPart }, + limit: LIST_LIMIT, + }, + 'q', + ], + ]); + const ids = this.expect(query, 'x:Account/query').ids ?? []; + if (ids.length === 0) return null; + const accounts = await this.getAccounts(ids); + return ( + accounts.find( + (account) => asString(account.emailAddress)?.toLowerCase() === address, + ) ?? null + ); + } + + private async getAccounts(ids: readonly string[]): Promise { + const [get] = await this.call([ + [ + 'x:Account/get', + { + accountId: this.session().accountId, + ids, + properties: ACCOUNT_PROPERTIES, + }, + 'g', + ], + ]); + return this.expect>(get, 'x:Account/get').list ?? []; + } + + private async getAll( + type: 'Account' | 'MailingList', + properties: readonly string[] | null, + ): Promise { + const accountId = this.session().accountId; + const responses = await this.call([ + [`x:${type}/query`, { accountId, limit: LIST_LIMIT }, 'q'], + [ + `x:${type}/get`, + { + accountId, + '#ids': { resultOf: 'q', name: `x:${type}/query`, path: '/ids' }, + ...(properties ? { properties } : {}), + }, + 'g', + ], + ]); + const get = responses.find((invocation) => invocation[2] === 'g'); + return this.expect>(get, `x:${type}/get`).list ?? []; + } + + private async requireDomainId(): Promise { + if (this.domainId) return this.domainId; + const domain = this.settings.domain; + const accountId = this.session().accountId; + const responses = await this.call([ + ['x:Domain/query', { accountId, limit: LIST_LIMIT }, 'q'], + [ + 'x:Domain/get', + { + accountId, + '#ids': { resultOf: 'q', name: 'x:Domain/query', path: '/ids' }, + properties: ['name'], + }, + 'g', + ], + ]); + const get = responses.find((invocation) => invocation[2] === 'g'); + const found = ( + this.expect>( + get, + 'x:Domain/get', + ).list ?? [] + ).find((item) => asString(item.name)?.toLowerCase() === domain); + if (!found?.id) throw new OpsEngineError('not-found', 'domain'); + this.domainId = found.id; + return found.id; + } + + private async requirePasswordCredential(id: string): Promise { + const [account] = await this.getAccounts([id]); + if (!account) throw new OpsEngineError('not-found'); + const credentialId = passwordCredentialId(account); + if (credentialId === null) { + throw new OpsEngineError('not-found', 'password-credential'); + } + return credentialId; + } + + private async update( + type: 'Account' | 'MailingList', + id: string, + patch: Record, + ): Promise { + const result = await this.set(type, { update: { [id]: patch } }); + const failure = result.notUpdated?.[id]; + if (failure) throw refusal(failure); + if (!result.updated || !(id in result.updated)) { + throw new OpsEngineError('refused', 'not-updated'); + } + } + + private async set( + type: 'Account' | 'MailingList', + args: Record, + ): Promise { + const [invocation] = await this.call([ + [`x:${type}/set`, { accountId: this.session().accountId, ...args }, 's'], + ]); + return this.expect(invocation, `x:${type}/set`); + } + + private expect(invocation: JmapInvocation | undefined, method: string): T { + if (!invocation) throw new OpsEngineError('unavailable', 'no-response'); + const [name, args] = invocation; + if (name === 'error') { + // 메서드의 forbidden 은 그 일을 할 권한이 없다는 뜻이다(예: Group 계정 만들기) — 로그인이 끊긴 것은 HTTP 401 · 403 뿐이다(call) + throw new OpsEngineError( + 'refused', + (args as JmapError).type ?? 'unknown', + ); + } + if (name !== method) throw new OpsEngineError('unavailable', String(name)); + return args as T; + } + + private session(): AdminSession { + if (!this.admin) throw new OpsEngineError('not-signed-in'); + return this.admin; + } + + private async call(methodCalls: JmapInvocation[]): Promise { + const session = this.session(); + const body = await this.exchange( + session.apiPath, + { + method: 'POST', + headers: { + authorization: session.authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + using: [CORE_CAPABILITY, MANAGEMENT_CAPABILITY], + methodCalls, + }), + }, + async (response) => { + if ( + response.status === HTTP_UNAUTHORIZED || + response.status === HTTP_FORBIDDEN + ) { + await discard(response); + this.admin = null; + throw new OpsEngineError('not-signed-in', `http-${response.status}`); + } + if (!response.ok) { + await discard(response); + throw new OpsEngineError('unavailable', `http-${response.status}`); + } + return (await readJson(response)) as JmapResponseBody | null; + }, + ); + if (!body || !Array.isArray(body.methodResponses)) { + throw new OpsEngineError('unavailable', 'malformed-response'); + } + return body.methodResponses; + } + + /** + * 엔진에 한 번 — 머리글을 받고 본문을 다 읽을 때까지 **한 제한 시간** 안에서(머리글 뒤 본문이 멈춰도 도구가 기다리지 않게), + * 다른 곳으로 보내지 않는다(인증 머리글이 다른 호스트로 가지 않게) + */ + private async exchange( + path: string, + init: RequestInit, + handle: (response: Response) => Promise, + ): Promise { + if (!isMailEngineConfigured(this.settings)) { + throw new OpsEngineError('unavailable', 'mail-not-configured'); + } + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + ADMIN_REQUEST_TIMEOUT_MS, + ); + try { + const response = await fetch(`${this.settings.jmapBaseUrl}${path}`, { + ...init, + redirect: 'manual', + signal: controller.signal, + }); + return await handle(response); + } catch (error) { + if ( + error instanceof OpsEngineError || + error instanceof BusinessException + ) { + throw error; + } + throw new OpsEngineError( + 'unavailable', + controller.signal.aborted ? 'timeout' : 'network', + ); + } finally { + clearTimeout(timer); + } + } +} + +/** 읽지 않을 본문을 버린다 (연결을 돌려준다) */ +async function discard(response: Response): Promise { + await response.body?.cancel().catch(() => undefined); +} + +/** + * 본문 JSON — 모양이 JSON 이 아니면 null(부르는 쪽이 malformed 로). 읽다가 끊기거나 시간이 다 되면 던진다(exchange 가 timeout · network 로) + */ +async function readJson(response: Response): Promise { + const text = await response.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + +function isLockRule(rule: MailFilterRule): boolean { + return rule.name === LOCK_FORWARD_RULE_NAME; +} diff --git a/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts b/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts new file mode 100644 index 0000000..77caf66 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-system-mailbox.spec.ts @@ -0,0 +1,207 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { SystemMailMessage } from '@/modules/system-mail/system-mailbox.port'; +import type { JmapClient } from './jmap.client'; +import { JmapSystemMailbox, buildSystemMailRaw } from './jmap-system-mailbox'; +import { decodeHeaderText } from './mime-text'; + +const ARA = 'ara.kim@arasystem.test'; + +const message: SystemMailMessage = { + kind: 'password-reset', + to: { email: ARA, name: '김아라' }, + subject: '관리자가 비밀번호를 재설정했습니다', + html: '

안녕하세요 — 알림

', + text: '안녕하세요 — 알림\n\n끝', +}; + +const settings = { + domain: 'arasystem.test', +} as unknown as MailSettings; + +/** 원문의 머리글 한 줄 */ +function header(raw: string, name: string): string | undefined { + const head = raw.slice(0, raw.indexOf('\r\n\r\n')); + return head + .split('\r\n') + .find((line) => line.toLowerCase().startsWith(`${name.toLowerCase()}:`)) + ?.slice(name.length + 1) + .trim(); +} + +/** multipart 의 부분 하나를 풀어 본문 글로 */ +function partBody(raw: string, type: string): string { + const start = raw.indexOf(`Content-Type: ${type}`); + const body = raw.slice(raw.indexOf('\r\n\r\n', start) + 4); + const end = body.indexOf('\r\n--'); + return Buffer.from( + body.slice(0, end).replace(/\r\n/g, ''), + 'base64', + ).toString('utf8'); +} + +describe('buildSystemMailRaw — 알림 메일 원문 (13단계)', () => { + const raw = buildSystemMailRaw({ + from: 'postmaster@arasystem.test', + message, + date: new Date('2026-10-30T06:20:00Z'), + messageId: 'ara-notice-1@arasystem.test', + boundary: 'ara-notice-b', + }); + + it('보낸 사람 ARA SYSTEM , 받는 사람 이름 <주소>, 자동 발송 표시', () => { + expect(header(raw, 'From')).toBe('ARA SYSTEM '); + expect(decodeHeaderText(header(raw, 'To') ?? '')).toBe(`김아라 <${ARA}>`); + expect(decodeHeaderText(header(raw, 'Subject') ?? '')).toBe( + '관리자가 비밀번호를 재설정했습니다', + ); + expect(header(raw, 'Date')).toBe('Fri, 30 Oct 2026 06:20:00 +0000'); + expect(header(raw, 'Message-ID')).toBe(''); + expect(header(raw, 'Auto-Submitted')).toBe('auto-generated'); + expect(header(raw, 'X-Ara-Notice')).toBe('password-reset'); + expect(header(raw, 'Content-Type')).toBe( + 'multipart/alternative; boundary="ara-notice-b"', + ); + }); + + it('머리는 ASCII 뿐이고(한글은 encoded-word), 텍스트 · HTML 두 부분이 같은 글을 담는다', () => { + const head = raw.slice(0, raw.indexOf('\r\n\r\n')); + expect(head).toMatch(/^[\x20-\x7e\r\n]*$/); + expect(partBody(raw, 'text/plain')).toBe( + message.text.replace(/\n/g, '\r\n'), + ); + expect(partBody(raw, 'text/html')).toBe(message.html); + expect(raw.endsWith('--ara-notice-b--\r\n')).toBe(true); + }); + + it('주소에 줄바꿈 · 꺾쇠가 있으면 만들지 않는다 (머리글이 깨지지 않게)', () => { + for (const email of [ + 'a@b.test\r\nBcc: x@y.test', + '', + 'a b@c.test', + ]) { + expect(() => + buildSystemMailRaw({ + from: 'postmaster@arasystem.test', + message: { ...message, to: { email, name: 'x' } }, + date: new Date(), + messageId: 'm@x', + boundary: 'b', + }), + ).toThrow(BusinessException); + } + }); +}); + +describe('JmapSystemMailbox — 받은편지함에 넣기 (13단계)', () => { + let jmap: { + call: jest.Mock; + upload: jest.Mock; + getSession: jest.Mock; + }; + let mailbox: JmapSystemMailbox; + + beforeEach(() => { + jmap = { + call: jest.fn(), + upload: jest.fn().mockResolvedValue({ + blobId: 'blob-1', + type: 'message/rfc822', + size: 1000, + }), + getSession: jest.fn().mockResolvedValue({ username: ARA }), + }; + mailbox = new JmapSystemMailbox(jmap as unknown as JmapClient, settings); + }); + + it('원문을 올리고 받은편지함에 안 읽음 · 받은 시각 지금으로 Email/import 한다 → 메일 id', async () => { + jmap.call + .mockResolvedValueOnce([ + [ + 'Mailbox/get', + { + list: [ + { id: 'm-sent', role: 'sent' }, + { id: 'm-inbox', role: 'inbox' }, + ], + }, + 'm', + ], + ]) + .mockResolvedValueOnce([ + ['Email/import', { created: { notice: { id: 'e-1' } } }, 'i'], + ]); + + await expect(mailbox.deliver(ARA, message)).resolves.toBe('e-1'); + + expect(jmap.upload).toHaveBeenCalledWith( + ARA, + expect.any(Blob), + 'message/rfc822', + ); + const build = (jmap.call.mock.calls[1] as unknown[])[1] as ( + id: string, + ) => unknown[]; + const [[method, args]] = build('acc') as [ + [string, { emails: Record> }], + ]; + expect(method).toBe('Email/import'); + expect(args.emails.notice).toMatchObject({ + blobId: 'blob-1', + mailboxIds: { 'm-inbox': true }, + keywords: {}, + }); + expect(args.emails.notice.receivedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, + ); + }); + + it('엔진이 넣지 않으면(notCreated) 코드 있는 실패로 던진다', async () => { + jmap.call + .mockResolvedValueOnce([ + ['Mailbox/get', { list: [{ id: 'm-inbox', role: 'inbox' }] }, 'm'], + ]) + .mockResolvedValueOnce([ + [ + 'Email/import', + { notCreated: { notice: { type: 'overQuota' } } }, + 'i', + ], + ]); + + await expect(mailbox.deliver(ARA, message)).rejects.toBeInstanceOf( + BusinessException, + ); + }); + + it('받은편지함이 없으면 MAIL_001 — 올리지 않는다', async () => { + jmap.call.mockResolvedValueOnce([['Mailbox/get', { list: [] }, 'm']]); + await expect(mailbox.deliver(ARA, message)).rejects.toMatchObject({ + code: 'MAIL_001', + }); + expect(jmap.upload).not.toHaveBeenCalled(); + }); + + it('지우기는 방금 넣은 메일 하나만 destroy — 이미 없으면 그대로 끝난다', async () => { + jmap.call.mockResolvedValueOnce([ + ['Email/set', { notDestroyed: { 'e-1': { type: 'notFound' } } }, 'd'], + ]); + await expect(mailbox.remove(ARA, 'e-1')).resolves.toBeUndefined(); + const build = (jmap.call.mock.calls[0] as unknown[])[1] as ( + id: string, + ) => unknown[]; + expect(build('acc')).toEqual([ + ['Email/set', { accountId: 'acc', destroy: ['e-1'] }, 'd'], + ]); + }); + + it('표시 이름 — 직원 주소의 identity 이름, 없거나 읽지 못하면 @ 앞', async () => { + jmap.call.mockResolvedValueOnce([ + ['Identity/get', { list: [{ email: ARA, name: '김아라' }] }, 'i'], + ]); + await expect(mailbox.displayNameOf(ARA)).resolves.toBe('김아라'); + + jmap.call.mockRejectedValueOnce(new BusinessException('MAIL_001')); + await expect(mailbox.displayNameOf(ARA)).resolves.toBe('ara.kim'); + }); +}); diff --git a/backend/src/modules/mail/jmap/jmap-system-mailbox.ts b/backend/src/modules/mail/jmap/jmap-system-mailbox.ts new file mode 100644 index 0000000..3971e13 --- /dev/null +++ b/backend/src/modules/mail/jmap/jmap-system-mailbox.ts @@ -0,0 +1,206 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { randomBytes, randomUUID } from 'crypto'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import type { + SystemMailbox, + SystemMailMessage, +} from '@/modules/system-mail/system-mailbox.port'; +import { JmapClient } from './jmap.client'; +import { + fallbackDisplayName, + pickIdentity, + toJmapUtcDate, +} from './jmap.mapper'; +import { expectResult, firstSetError } from './jmap-results'; +import { jmapFailure } from './jmap.errors'; +import type { + JmapGetResult, + JmapIdentity, + JmapMailbox, + JmapSetResult, +} from './jmap.types'; +import { base64TextBody, encodeHeaderWords } from './mime-text'; + +/** 보낸 사람 표시 이름 (계약 6절 — `ARA SYSTEM >`) */ +export const SYSTEM_SENDER_NAME = 'ARA SYSTEM'; +/** 보낸 사람 주소의 로컬 부분 */ +export const SYSTEM_SENDER_LOCAL_PART = 'postmaster'; + +const CRLF = '\r\n'; +/** 원문의 형식 — Email/import 는 올린 blob 을 메일 원문으로 읽는다 */ +const MESSAGE_TYPE = 'message/rfc822'; +/** 알림 메일을 엔진에 넣을 때의 이름 */ +const IMPORT_KEY = 'notice'; + +/** 원문의 머리 · 본문을 만드는 데 쓰는 값 */ +export interface SystemMailRawInput { + readonly from: string; + readonly message: SystemMailMessage; + readonly date: Date; + readonly messageId: string; + readonly boundary: string; +} + +/** 머리글 값에 줄바꿈 · 꺾쇠가 들어가지 못하게 — 주소는 서버가 아는 직원 주소지만 원문을 깨뜨리지 않게 한 번 더 본다 */ +const SAFE_ADDRESS = /^[^\s<>"\\,;:@]+@[^\s<>"\\,;:@]+$/; + +/** RFC 5322 날짜 — 'Mon, 02 Nov 2026 02:40:00 +0000' */ +function rfc5322Date(date: Date): string { + return date.toUTCString().replace(/GMT$/, '+0000'); +} + +/** + * 알림 메일의 원문 (순수 함수) — multipart/alternative(텍스트 · HTML, 둘 다 UTF-8 base64). + * - 받는 사람 이름 · 제목은 RFC 2047 encoded-word(ASCII 만 남는다 — 머리글에 줄바꿈이 끼지 못한다) + * - `Auto-Submitted: auto-generated`(RFC 3834) — 부재중 자동 응답 · 다른 자동 응답이 이 메일에 답하지 않게 + * - `X-Ara-Notice` — 알림의 종류 (사람은 보지 않는다 — 검색 · 문제 확인용) + */ +export function buildSystemMailRaw(input: SystemMailRawInput): string { + const { from, message, date, messageId, boundary } = input; + if (!SAFE_ADDRESS.test(from) || !SAFE_ADDRESS.test(message.to.email)) { + throw new BusinessException('MAIL_001', { + context: { reason: 'system-mail-address-invalid' }, + }); + } + const headers = [ + `From: ${SYSTEM_SENDER_NAME} <${from}>`, + `To: ${encodeHeaderWords(message.to.name)} <${message.to.email}>`, + `Subject: ${encodeHeaderWords(message.subject)}`, + `Date: ${rfc5322Date(date)}`, + `Message-ID: <${messageId}>`, + 'MIME-Version: 1.0', + 'Auto-Submitted: auto-generated', + `X-Ara-Notice: ${message.kind}`, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + ]; + const part = (type: string, body: string) => + [ + `--${boundary}`, + `Content-Type: ${type}; charset="utf-8"`, + 'Content-Transfer-Encoding: base64', + '', + base64TextBody(body), + ].join(CRLF); + return [ + ...headers, + '', + part('text/plain', message.text), + part('text/html', message.html), + `--${boundary}--`, + '', + ].join(CRLF); +} + +/** + * Stalwart(JMAP) 알림 메일 어댑터 — SystemMailbox 의 구현 (13단계 — 계약 6절 "받은편지함 알림 메일"). + * 게이트웨이 대리 접근으로 원문을 올리고(`message/rfc822`) `Email/import` 로 받은편지함에 넣는다 — 안 읽음(`keywords {}`) · 받은 시각 지금. + * 엔진 안의 JMAP 생성이라 SPF · DKIM · 필터 · 스팸 판정을 거치지 않는다 (2026-10-31 개발 엔진 확인). + * 그 직원의 비밀번호가 만료됐거나(메일 기능이 없다) 잠겼으면(대리 접근 403) 넣지 못한다 — 부르는 쪽이 차례를 지킨다(운영 도구). + * 메일 내용은 로그 · 오류 context 에 남기지 않는다 + */ +@Injectable() +export class JmapSystemMailbox implements SystemMailbox { + constructor( + private readonly jmap: JmapClient, + @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, + ) {} + + async deliver(user: string, message: SystemMailMessage): Promise { + const { domain } = this.settings; + if (!domain) { + throw new BusinessException('MAIL_001', { + context: { reason: 'mail-not-configured' }, + }); + } + const inboxId = await this.inboxOf(user); + const raw = buildSystemMailRaw({ + from: `${SYSTEM_SENDER_LOCAL_PART}@${domain}`, + message, + date: new Date(), + messageId: `ara-notice-${randomUUID()}@${domain}`, + boundary: `ara-notice-${randomBytes(12).toString('hex')}`, + }); + const blob = await this.jmap.upload( + user, + new Blob([raw], { type: MESSAGE_TYPE }), + MESSAGE_TYPE, + ); + const responses = await this.jmap.call(user, (accountId) => [ + [ + 'Email/import', + { + accountId, + emails: { + [IMPORT_KEY]: { + blobId: blob.blobId, + mailboxIds: { [inboxId]: true }, + keywords: {}, + receivedAt: toJmapUtcDate(new Date().toISOString()), + }, + }, + }, + 'i', + ], + ]); + const result = expectResult(responses, 'i', 'Email/import'); + const refusal = firstSetError(result.notCreated); + if (refusal) throw jmapFailure(refusal, { method: 'Email/import' }); + const id = result.created?.[IMPORT_KEY]?.id; + if (typeof id !== 'string' || id === '') { + throw new BusinessException('MAIL_001', { + context: { reason: 'system-mail-import-unanswered' }, + }); + } + return id; + } + + /** 방금 넣은 알림만 지운다 — 이 요청이 만든 메일이라 조건 없이 지운다(nestjs 스킬 "조건 없는 destroy 는 방금 만든 메일에만") */ + async remove(user: string, emailId: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + ['Email/set', { accountId, destroy: [emailId] }, 'd'], + ]); + const result = expectResult(responses, 'd', 'Email/set'); + const refusal = Object.values(result.notDestroyed ?? {}).find( + (error) => error?.type !== 'notFound', + ); + if (refusal) throw jmapFailure(refusal, { method: 'Email/set' }); + } + + async displayNameOf(user: string): Promise { + try { + const session = await this.jmap.getSession(user); + const responses = await this.jmap.call(user, (accountId) => [ + ['Identity/get', { accountId, ids: null }, 'i'], + ]); + const { list = [] } = expectResult>( + responses, + 'i', + 'Identity/get', + ); + const name = pickIdentity(list, session.username)?.name?.trim(); + return name || fallbackDisplayName(user); + } catch { + return fallbackDisplayName(user); + } + } + + private async inboxOf(user: string): Promise { + const responses = await this.jmap.call(user, (accountId) => [ + ['Mailbox/get', { accountId, ids: null, properties: ['role'] }, 'm'], + ]); + const { list = [] } = expectResult>( + responses, + 'm', + 'Mailbox/get', + ); + const inbox = list.find((mailbox) => mailbox.role === 'inbox'); + if (!inbox?.id) { + throw new BusinessException('MAIL_001', { + context: { reason: 'inbox-missing' }, + }); + } + return inbox.id; + } +} diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index 3c810b9..f475bee 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -1,6 +1,10 @@ import type { Cache } from 'cache-manager'; import { BusinessException } from '@/common/errors/business.exception'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import type { MailSettings } from '@/modules/mail/mail.settings'; import { runWithRequestContext } from '@/shared/logger/request-context'; import { @@ -11,6 +15,7 @@ import { classifyPasswordChange, expandTemplate, toRelativeReference, + isPasswordExpiredPermissions, } from './jmap.client'; import type { JmapInvocation } from './jmap.types'; @@ -619,17 +624,15 @@ describe('JmapClient', () => { ).resolves.toBe('outcome-unknown'); }); - it('엔진이 계정 관리 기능을 알려 주지 않으면 세션을 새로 받고, 그래도 없으면 MAIL_001 — 바꾸기를 보내지 않는다', async () => { - fetchMock - .mockResolvedValueOnce(json(sessionBody)) - .mockResolvedValueOnce(json(sessionBody)); + it('엔진이 계정 관리 기능을 알려 주지 않으면(세션 자원을 새로 읽어도) MAIL_001 — 바꾸기를 보내지 않는다', async () => { + fetchMock.mockResolvedValueOnce(json(sessionBody)); expect( await codeOf( client.changePasswordWithCode(ARA, 'old-pw', 'new-pw-1234', '123456'), ), ).toBe('MAIL_001'); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); @@ -719,6 +722,116 @@ describe('JmapClient', () => { }); }); + describe('비밀번호가 만료된 계정 (13단계 — 계약 6절)', () => { + /** 만료된 계정의 세션 자원 — 메일 기능이 없고 계정 관리 기능만 있다 (2026-11-02 개발 엔진) */ + const expiredSession = { + ...sessionBody, + primaryAccounts: { 'urn:stalwart:jmap': 'x1' }, + }; + const EXPIRED_PERMISSIONS = [ + 'emailReceive', + 'authenticateWithAlias', + 'authenticate', + 'sysAccountPasswordUpdate', + 'sysAccountPasswordGet', + ]; + + it('메일 기능이 없고 권한이 만료의 다섯뿐이면 PasswordExpiredException — 정식 주소와 @ 앞 이름, 세션은 기억하지 않는다', async () => { + fetchMock + .mockResolvedValueOnce( + json({ ...expiredSession, username: 'Ara.Kim@arasystem.test' }), + ) + .mockResolvedValueOnce( + json({ permissions: EXPIRED_PERMISSIONS, edition: 'community' }), + ); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(PasswordExpiredException); + // 메일 API 의 응답으로 나가면 401 — 화면이 곧 갱신해 새 비밀번호 단계(또는 세션 만료)로 간다 + expect((failure as PasswordExpiredException).code).toBe('AUTH_001'); + expect((failure as PasswordExpiredException).account).toBe(ARA); + expect((failure as PasswordExpiredException).displayName).toBe('ara.kim'); + expect(requestAt(1).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(1).authorization)).toBe( + `${ARA}%mail-gateway@arasystem.test:gateway-secret`, + ); + expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy(); + }); + + it('메일 기능이 없지만 만료의 모양이 아니면(역할 설정 실수 등) 지금처럼 MAIL_001 — 만료로 보지 않는다', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce(json({ permissions: ['authenticate'] })); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(BusinessException); + expect(failure).not.toBeInstanceOf(PasswordExpiredException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + + it('계정 정보를 읽지 못하면 만료로 보지 않고 MAIL_001', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce(json({ type: 'error' }, 500)); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).not.toBeInstanceOf(PasswordExpiredException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + + it('계정 관리 호출은 만료된 계정에도 열린다 — 메일 기능 없이 계정 관리 계정 id 로 (2단계 인증 상태 · 2단계 직원의 비밀번호 바꾸기)', async () => { + fetchMock + .mockResolvedValueOnce(json(expiredSession)) + .mockResolvedValueOnce( + json({ + methodResponses: [['x:AccountPassword/get', { list: [{}] }, 'g']], + }), + ); + + await client.callAccount(ARA, (accountId) => [ + ['x:AccountPassword/get', { accountId, ids: ['singleton'] }, 'g'], + ]); + + expect(JSON.parse(requestAt(1).init.body as string)).toEqual({ + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [ + [ + 'x:AccountPassword/get', + { accountId: 'x1', ids: ['singleton'] }, + 'g', + ], + ], + }); + }); + + it('만료의 모양 (isPasswordExpiredPermissions) — 바꾸기가 있고 다섯 밖의 권한이 없을 때만', () => { + expect(isPasswordExpiredPermissions(EXPIRED_PERMISSIONS)).toBe(true); + expect( + isPasswordExpiredPermissions([ + 'authenticate', + 'sysAccountPasswordUpdate', + ]), + ).toBe(true); + expect( + isPasswordExpiredPermissions([...EXPIRED_PERMISSIONS, 'jmapEmailGet']), + ).toBe(false); + expect(isPasswordExpiredPermissions(['authenticate'])).toBe(false); + expect(isPasswordExpiredPermissions([])).toBe(false); + expect(isPasswordExpiredPermissions(undefined)).toBe(false); + expect(isPasswordExpiredPermissions('sysAccountPasswordUpdate')).toBe( + false, + ); + }); + }); + describe('대리 접근 세션 (getSession)', () => { it('<직원>%<게이트웨이>:<게이트웨이 비밀번호> 로 인증하고, 공개 주소 URL 을 경로로 바꿔 둔다', async () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); @@ -830,13 +943,20 @@ describe('JmapClient', () => { ), ); + // 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제 + fetchMock.mockResolvedValueOnce(json({ permissions: ['authenticate'] })); + const code = await runWithRequestContext({ requestId: 'trace-403' }, () => codeOf(client.getSession(ARA)), ); expect(code).toBe('MAIL_001'); - // 게이트웨이 확인을 하지 않는다 — 403 은 인증이 이미 된 상태다 - expect(fetchMock).toHaveBeenCalledTimes(1); + // 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다) + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestAt(1).url).toBe('http://mail:8080/api/account'); + expect(decodeBasic(requestAt(1).authorization)).toBe( + 'mail-gateway@arasystem.test:gateway-secret', + ); expect(errorLog).toHaveBeenCalledWith( expect.stringMatching( /^\[MAIL_001\]\[trace-403\] .*대리 접근 권한이 없습니다/, @@ -844,6 +964,61 @@ describe('JmapClient', () => { ); }); + it('대리 접근 403 인데 게이트웨이는 대리 접근할 수 있으면 그 계정 문제(잠김) — AccountLoginDisabledException(MAIL_001) · warn, 확인은 1분 기억', async () => { + const errorLog = jest + .spyOn( + Reflect.get(client, 'logger') as { error: (message: string) => void }, + 'error', + ) + .mockImplementation(() => undefined); + const warnLog = jest + .spyOn( + Reflect.get(client, 'logger') as { warn: (message: string) => void }, + 'warn', + ) + .mockImplementation(() => undefined); + fetchMock + .mockResolvedValueOnce(json({ title: 'Forbidden', status: 403 }, 403)) + .mockResolvedValueOnce( + json({ permissions: ['authenticate', 'impersonate'] }), + ); + + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AccountLoginDisabledException); + expect((failure as AccountLoginDisabledException).code).toBe('MAIL_001'); + expect((failure as AccountLoginDisabledException).account).toBe(ARA); + expect(errorLog).not.toHaveBeenCalled(); + expect(warnLog).toHaveBeenCalledWith( + expect.stringContaining('그 계정에 로그인 권한이 없습니다(잠김)'), + ); + + // 1분 안의 다음 403 은 게이트웨이를 다시 묻지 않는다 + fetchMock.mockResolvedValueOnce(json({ title: 'Forbidden' }, 403)); + await expect( + client.getSession(ARA, { fresh: true }), + ).rejects.toBeInstanceOf(AccountLoginDisabledException); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('게이트웨이 권한을 읽지 못하면 설정 문제 쪽으로 — 지금까지와 같은 MAIL_001 + error', async () => { + jest + .spyOn( + Reflect.get(client, 'logger') as { error: (message: string) => void }, + 'error', + ) + .mockImplementation(() => undefined); + fetchMock + .mockResolvedValueOnce(json({ title: 'Forbidden' }, 403)) + .mockRejectedValueOnce(new TypeError('fetch failed')); + const failure = await client + .getSession(ARA, { fresh: true }) + .catch((error: unknown) => error); + expect(failure).not.toBeInstanceOf(AccountLoginDisabledException); + expect((failure as BusinessException).code).toBe('MAIL_001'); + }); + it('막는 계정(메일 관리자)은 엔진에 대리 접근을 요청하지 않고 AUTH_001 (대소문자 무관)', async () => { for (const user of ['admin@arasystem.test', ' Admin@AraSystem.TEST']) { expect(await codeOf(client.getSession(user, { fresh: true }))).toBe( @@ -994,6 +1169,54 @@ describe('JmapClient', () => { fetchMock.mockResolvedValueOnce(json(sessionBody)); }); + it('메일 메서드가 forbidden 이고 그 계정이 비밀번호 만료 모양이면 PasswordExpiredException(401 AUTH_001) — 기억한 세션도 버린다 (실사용 시험)', async () => { + // 세션은 기억에서 — 그사이 운영 도구가 비밀번호를 재설정했다(엔진이 권한을 줄였다) + await client.getSession(ARA); + fetchMock + .mockResolvedValueOnce( + json({ + methodResponses: [ + ['error', { type: 'forbidden' }, 'q'], + ['error', { type: 'invalidResultReference' }, 'g'], + ], + }), + ) + .mockResolvedValueOnce( + json({ + permissions: [ + 'authenticate', + 'authenticateWithAlias', + 'sysAccountPasswordGet', + 'sysAccountPasswordUpdate', + 'emailReceive', + ], + }), + ); + + const failure = await client + .call(ARA, (accountId) => [ + ['EmailSubmission/query', { accountId }, 'q'], + ]) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(PasswordExpiredException); + expect((failure as PasswordExpiredException).code).toBe('AUTH_001'); + expect(await cache.get(`mail:jmap-session:${ARA}`)).toBeFalsy(); + }); + + it('메일 메서드가 forbidden 이어도 만료 모양이 아니면 응답을 그대로 돌려준다 — 해석은 부른 쪽(AUTH_003 등)', async () => { + const responses = [['error', { type: 'forbidden' }, 'm']]; + fetchMock + .mockResolvedValueOnce(json({ methodResponses: responses })) + .mockResolvedValueOnce( + json({ + permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'], + }), + ); + await expect( + client.call(ARA, (accountId) => [['Mailbox/get', { accountId }, 'm']]), + ).resolves.toEqual(responses); + }); + it('세션의 accountId 로 호출을 만들고 내부 주소의 API 경로로 보낸다', async () => { const responses = [['Mailbox/get', { list: [] }, 'm']]; fetchMock.mockResolvedValueOnce(json({ methodResponses: responses })); diff --git a/backend/src/modules/mail/jmap/jmap.client.ts b/backend/src/modules/mail/jmap/jmap.client.ts index 5b01bc7..9135941 100644 --- a/backend/src/modules/mail/jmap/jmap.client.ts +++ b/backend/src/modules/mail/jmap/jmap.client.ts @@ -6,6 +6,10 @@ import type { ReadableStream as NodeReadableStream } from 'stream/web'; import { BusinessException } from '@/common/errors/business.exception'; import type { ErrorCode } from '@/common/errors/error-codes'; +import { + AccountLoginDisabledException, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, isMailEngineConfigured, @@ -19,6 +23,7 @@ import type { } from '@/modules/mail/mail.types'; import { traceTag } from '@/shared/logger/request-context'; import { maskEmail } from '@/shared/utils/mask'; +import { fallbackDisplayName } from './jmap.mapper'; import type { JmapInvocation, JmapProblem, @@ -142,6 +147,64 @@ export function classifyPasswordChange( return { kind: 'unknown', detail: String(name) }; } +/** + * 엔진의 계정 정보(권한 목록) — Stalwart 관리 API. 본인 Basic · 대리 접근 모두 그 계정의 지금 권한을 준다 + * (`{ permissions: [...], edition, locale }` — 2026-11-02 개발 엔진(0.16.27) 확인) + */ +const ACCOUNT_INFO_PATH = '/api/account'; + +/** + * 비밀번호 자격이 만료된 계정의 권한 — 엔진이 이 다섯으로 줄인다 (0.16.27 소스 `access_token.rs` 의 assert_is_valid · 2026-11-02 개발 엔진 확인: + * 본인 Basic 은 200, 대리 접근 세션도 열리지만 둘 다 메일 기능이 없다. 본인이 비밀번호를 바꾸면 엔진이 만료를 스스로 푼다) + */ +const EXPIRED_PASSWORD_PERMISSIONS: ReadonlySet = new Set([ + 'authenticate', + 'authenticateWithAlias', + 'sysAccountPasswordGet', + 'sysAccountPasswordUpdate', + 'emailReceive', +]); + +/** + * 권한 목록이 "비밀번호 만료"의 모양인가 (순수 함수) — 비밀번호 바꾸기가 있고 다섯 밖의 권한이 하나도 없다. + * 역할 설정 실수로 메일 권한만 빠진 계정(바꾸기도 없음)은 만료가 아니다 — 비밀번호를 바꿔도 풀리지 않으니 바꾸기 단계로 보내지 않는다 + */ +export function isPasswordExpiredPermissions(permissions: unknown): boolean { + return ( + Array.isArray(permissions) && + permissions.includes('sysAccountPasswordUpdate') && + permissions.every( + (permission) => + typeof permission === 'string' && + EXPIRED_PASSWORD_PERMISSIONS.has(permission), + ) + ); +} + +/** 엔진 계정 정보의 응답 — 권한 목록만 읽는다 */ +interface AccountInfoReply { + readonly permissions?: unknown; +} + +/** 대리 접근 권한의 이름 — 게이트웨이 자신의 권한(/api/account)에 있어야 한다 (2026-11-02 개발 엔진: authenticate · impersonate 둘) */ +const IMPERSONATE_PERMISSION = 'impersonate'; +/** 게이트웨이가 대리 접근할 수 있다고 확인한 것을 기억하는 시간 — 잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게 */ +const GATEWAY_IMPERSONATION_MEMORY_MS = 60_000; + +/** 메서드 오류 forbidden 인가 — 메일 메서드가 막혔다 */ +function isForbiddenInvocation(invocation: JmapInvocation): boolean { + return ( + invocation[0] === 'error' && + (invocation[1] as JmapProblem | undefined)?.type === 'forbidden' + ); +} + +/** 계정 관리 기능만 쓰는 세션 — 비밀번호가 만료돼 메일 기능이 없는 계정도 열린다 (2단계 인증 · 앱 비밀번호 · 2단계 직원의 비밀번호 바꾸기) */ +interface ManagementSession { + readonly apiPath: string; + readonly managementAccountId: string; +} + /** 일반 API 호출 제한 시간 — 응답 본문을 다 읽을 때까지. 오래 걸리는 처리는 남은 시간이 이보다 적으면 그만큼만 준다 (jmap-mail.provider.ts) */ export const JMAP_REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = JMAP_REQUEST_TIMEOUT_MS; @@ -235,11 +298,6 @@ export type AccountChangeReply = | { readonly kind: 'answered'; readonly responses: JmapInvocation[] } | { readonly kind: 'unknown'; readonly detail: string }; -/** 계정 관리 기능의 계정 id 가 있는 세션 */ -type AccountJmapSession = JmapSession & { - readonly managementAccountId: string; -}; - /** * 사내 목록 계정의 세션 (캐시에 저장되므로 평범한 객체로 둔다) — 이 세션에는 메일 계정이 없고 계정 목록(principals) 계정만 있다 * (2026-10-29 개발 엔진에서 확인). 직원 세션(JmapSession)으로 쓰지 않는다 @@ -391,6 +449,8 @@ export class JmapClient { private readonly logger = new Logger(JmapClient.name); /** 관리 권한 확인 응답을 해석하지 못했다는 error 로그를 이미 남겼는가 (엔진 API 가 바뀐 경우 — 한 번만 알린다) */ private managementProbeUnclearReported = false; + /** 게이트웨이가 대리 접근할 수 있다고 확인한 때부터 이 시각까지 다시 묻지 않는다 (GATEWAY_IMPERSONATION_MEMORY_MS) */ + private gatewayImpersonationKnownUntil = 0; constructor( @Inject(MAIL_SETTINGS) private readonly settings: MailSettings, @@ -725,20 +785,15 @@ export class JmapClient { if (cached) return cached; } - const session = await this.exchange( - SESSION_PATH, - { headers: { authorization: this.impersonation(user) } }, + const resource = await this.fetchSessionResource( + user, options.timeoutMs ?? REQUEST_TIMEOUT_MS, - 'session', - async (response) => { - await this.assertImpersonationAccepted(response, user); - await this.assertOk(response, 'session'); - return this.toSession( - await readJson(response, 'session'), - user, - ); - }, ); + // 메일 기능이 없다 — 비밀번호가 만료된 계정이면 그렇게 알린다(13단계), 아니면 아래 toSession 이 MAIL_001 + if (!resource.primaryAccounts?.[MAIL_CAPABILITY]) { + await this.throwIfPasswordExpired(user, resource); + } + const session = this.toSession(resource, user); // 엔진이 알려 준 정식 주소로 한 번 더 — 로컬 이름(admin)이나 별칭으로 들어와도 막는 계정이면 캐시하지 않는다 this.assertNotBlocked(session.username); @@ -746,6 +801,76 @@ export class JmapClient { return session; } + /** 대리 접근으로 엔진 세션 자원을 읽는다 — 401 · 403 은 다른 대리 접근 호출과 같은 분류 */ + private async fetchSessionResource( + user: string, + timeoutMs: number, + ): Promise { + return this.exchange( + SESSION_PATH, + { headers: { authorization: this.impersonation(user) } }, + timeoutMs, + 'session', + async (response) => { + await this.assertImpersonationAccepted(response, user); + await this.assertOk(response, 'session'); + return readJson(response, 'session'); + }, + ); + } + + /** + * 그 계정의 비밀번호가 만료됐으면 PasswordExpiredException 으로 던진다(기억한 세션도 버린다) — 아니거나 알 수 없으면 아무것도 하지 않는다. + * 세션은 열렸는데 메일 메서드가 forbidden 일 때 부른다(13단계): 엔진은 비밀번호를 바꾼 직후 잠깐(0.5초 남짓) 옛 세션 자원을 주고, + * 이 서버는 세션을 5분 기억한다 — 그 사이에 만료된 계정이면 프로필 읽기가 forbidden 으로 끝난다 + */ + async throwIfPasswordExpiredFor(user: string): Promise { + if (await this.isPasswordExpired(user)) { + await this.cache.del(sessionCacheKey(user)); + const account = user.trim().toLowerCase(); + throw new PasswordExpiredException(account, fallbackDisplayName(account)); + } + } + + /** 대리 접근으로 그 계정의 권한을 읽어 "비밀번호 만료"의 모양인가 — 읽지 못하면 false */ + private async isPasswordExpired(user: string): Promise { + try { + return await this.exchange( + ACCOUNT_INFO_PATH, + { headers: { authorization: this.impersonation(user) } }, + REQUEST_TIMEOUT_MS, + 'account-info', + async (response) => { + if (!response.ok) { + await discard(response); + return false; + } + const reply = await readJson( + response, + 'account-info', + ); + return isPasswordExpiredPermissions(reply.permissions); + }, + ); + } catch { + return false; + } + } + + /** + * 메일 기능이 없는 세션 — 그 계정의 권한이 "비밀번호 만료"의 다섯뿐이면 PasswordExpiredException(정식 주소)으로 던진다 (13단계 — 계약 6절). + * 권한을 읽지 못하거나 다른 모양이면 아무것도 하지 않는다(부르는 쪽이 지금처럼 MAIL_001 — 세션이 모자람) + */ + private async throwIfPasswordExpired( + user: string, + resource: JmapSessionResource, + ): Promise { + if (!(await this.isPasswordExpired(user))) return; + const account = resource.username?.trim().toLowerCase() || user; + this.assertNotBlocked(account); + throw new PasswordExpiredException(account, fallbackDisplayName(account)); + } + /** * 계정 관리 기능(`urn:stalwart:jmap` — x:AccountPassword · x:AppPassword)을 대리 접근으로 부른다 (12단계 — 2단계 인증 · 앱 비밀번호). * 계정 id 는 세션의 `primaryAccounts['urn:stalwart:jmap']`(메일 계정 id 와 따로 본다), `using` 은 core · 그 기능뿐이다. @@ -849,20 +974,47 @@ export class JmapClient { }; } - /** 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 이 값을 알기 전에 캐시한 세션이면 다시 받는다. 엔진이 알려 주지 않으면 MAIL_001 */ - private async accountSession(user: string): Promise { - let session = await this.getSession(user); - if (!session.managementAccountId) { - session = await this.getSession(user, { fresh: true }); + /** + * 계정 관리 기능의 계정 id 가 있는 대리 접근 세션 — 기억한 메일 세션에 그 값이 있으면 그것을, 없으면 세션 자원을 다시 읽는다. + * 메일 기능이 없는 계정(비밀번호 만료 — 13단계)도 연다: 만료된 동안에도 2단계 인증 상태 읽기 · 2단계 직원의 비밀번호 바꾸기는 엔진이 받는다 + * (2026-11-02 확인). 엔진이 그 기능을 알려 주지 않으면 MAIL_001 + */ + private async accountSession(user: string): Promise { + this.assertNotBlocked(user); + const cached = await this.cache.get(sessionCacheKey(user)); + if (cached?.managementAccountId) { + return { + apiPath: cached.apiPath, + managementAccountId: cached.managementAccountId, + }; } - const { managementAccountId } = session; - if (!managementAccountId) { + + const resource = await this.fetchSessionResource(user, REQUEST_TIMEOUT_MS); + this.assertNotBlocked(resource.username?.trim().toLowerCase() || user); + const managementAccountId = + resource.primaryAccounts?.[ACCOUNT_MANAGEMENT_CAPABILITY]; + if (!managementAccountId || !resource.apiUrl) { throw engineFailure({ reason: 'account-capability-missing', call: 'account-session', }); } - return { ...session, managementAccountId }; + // 메일 기능도 있으면 메일 세션으로 함께 기억한다 (다음 메일 호출이 다시 묻지 않게) + if (resource.primaryAccounts?.[MAIL_CAPABILITY]) { + try { + await this.cache.set( + sessionCacheKey(user), + this.toSession(resource, user), + SESSION_CACHE_TTL_MS, + ); + } catch { + // 메일 세션이 모자라도 계정 관리 호출은 이어 간다 + } + } + return { + apiPath: toRelativeReference(resource.apiUrl), + managementAccountId, + }; } /** @@ -962,7 +1114,7 @@ export class JmapClient { methodCalls: build(session.accountId), }); - return this.exchange( + const responses = await this.exchange( session.apiPath, { method: 'POST', @@ -1004,6 +1156,12 @@ export class JmapClient { return parsed.methodResponses; }, ); + // 메일 메서드가 막혔다 — 그사이 비밀번호가 만료된 계정이면(엔진이 권한을 비밀번호 바꾸기만으로 줄였다 · 세션은 5분 기억한다) 그렇게 알린다 (13단계). + // 아니면 그대로 돌려준다 — 해석은 부른 쪽이 한다(AUTH_003 등) + if (responses.some(isForbiddenInvocation)) { + await this.throwIfPasswordExpiredFor(user); + } + return responses; } /** @@ -1460,9 +1618,11 @@ export class JmapClient { * 대리 접근 거부를 해석한다 (Stalwart v0.16 개발 엔진에서 확인): * - **401**: 대상 계정이 없거나(다른 도메인 주소 포함) 게이트웨이 비밀번호가 틀렸다 — 응답이 같다. * 그래서 게이트웨이로 직접 로그인해 구분한다: 게이트웨이도 거부되면 설정 문제(MAIL_001), 아니면 계정 없음(AUTH_001) - * - **403** "You do not have enough permissions": 인증은 됐지만 **대리 접근 권한이 없다** — 게이트웨이 설정 문제다. - * 계정 없음으로 보면 설정 실수 한 번에 전 직원의 세션이 폐기된다(리프레시가 AUTH_001 → 세션 폐기). - * 그래서 MAIL_001(503) + error 로그로 답하고 세션은 건드리지 않는다. + * - **403** "You do not have enough permissions": 인증은 됐지만 대리 접근이 막혔다 — 게이트웨이에 대리 접근 권한이 없거나(설정 문제) + * **대상 계정에 로그인 권한(authenticate)이 없다**(잠김 — 13단계 운영 도구 · 엔진 관리 화면). 게이트웨이 자신의 권한(/api/account)에 + * impersonate 가 있으면 그 계정 문제다 → AccountLoginDisabledException(여전히 MAIL_001 — 로그인만 AUTH_004 로 바꾼다, warn). + * 없거나 읽지 못하면 설정 문제로 본다 → MAIL_001(503) + error. 어느 쪽도 계정 없음(AUTH_001)으로 보지 않는다 — + * 설정 실수 한 번에 전 직원의 세션이 폐기되지 않게(리프레시가 AUTH_001 → 세션 폐기). 세션은 건드리지 않는다. */ private async assertImpersonationAccepted( response: Response, @@ -1479,6 +1639,13 @@ export class JmapClient { await this.cache.del(sessionCacheKey(user)); if (response.status === HTTP_FORBIDDEN) { + if (await this.gatewayCanImpersonate()) { + // 게이트웨이는 대리 접근할 수 있다 — 이 계정이 로그인할 수 없다(잠김). 서버 장애가 아니다 + this.logger.warn( + `[MAIL_001]${traceTag()} 대리 접근이 막혔습니다(403) — 게이트웨이는 대리 접근할 수 있으니 그 계정에 로그인 권한이 없습니다(잠김): user=${maskEmail(user)}`, + ); + throw new AccountLoginDisabledException(user.trim().toLowerCase()); + } this.logger.error( `[MAIL_001]${traceTag()} 메일 게이트웨이 계정에 대리 접근 권한이 없습니다(403) — 계정 문제가 아니라 설정 문제로 보고 세션을 유지합니다. 게이트웨이 계정의 대리 접근(impersonate) 권한과 메일 엔진 설정(mail/scripts/setup.mjs)을 확인하세요.`, ); @@ -1504,6 +1671,48 @@ export class JmapClient { }); } + /** + * 게이트웨이가 대리 접근할 수 있는가 — 자신의 권한(/api/account)에 impersonate 가 있는가. 읽지 못하면 false(설정 문제 쪽 — 지금까지와 같은 503). + * 있으면 1분 기억한다(잠긴 계정의 로그인 시도마다 엔진에 더 묻지 않게) + */ + private async gatewayCanImpersonate(): Promise { + if (this.gatewayImpersonationKnownUntil > Date.now()) return true; + const { gatewayUser, gatewayPassword } = this.requireConfigured(); + try { + const allowed = await this.exchange( + ACCOUNT_INFO_PATH, + { + headers: { + authorization: basicAuthorization(gatewayUser, gatewayPassword), + }, + }, + REQUEST_TIMEOUT_MS, + 'gateway-permissions', + async (response) => { + if (!response.ok) { + await discard(response); + return false; + } + const reply = await readJson( + response, + 'gateway-permissions', + ); + return ( + Array.isArray(reply.permissions) && + reply.permissions.includes(IMPERSONATE_PERMISSION) + ); + }, + ); + if (allowed) { + this.gatewayImpersonationKnownUntil = + Date.now() + GATEWAY_IMPERSONATION_MEMORY_MS; + } + return allowed; + } catch { + return false; + } + } + private async gatewayAccepted(): Promise { const { gatewayUser, gatewayPassword } = this.requireConfigured(); return this.exchange( diff --git a/backend/src/modules/mail/mail-attachment.controller.ts b/backend/src/modules/mail/mail-attachment.controller.ts index 6bf3b63..979eae0 100644 --- a/backend/src/modules/mail/mail-attachment.controller.ts +++ b/backend/src/modules/mail/mail-attachment.controller.ts @@ -17,6 +17,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -36,7 +37,7 @@ import { MailService } from './mail.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('mail-attachments') export class MailAttachmentController { constructor(private readonly mail: MailService) {} diff --git a/backend/src/modules/mail/mail-block.controller.ts b/backend/src/modules/mail/mail-block.controller.ts index 6d9a922..c544b39 100644 --- a/backend/src/modules/mail/mail-block.controller.ts +++ b/backend/src/modules/mail/mail-block.controller.ts @@ -14,6 +14,7 @@ import type { Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -43,7 +44,7 @@ const BLOCK_NOTE = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse( 'MAIL_001', '메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다', diff --git a/backend/src/modules/mail/mail-events.controller.ts b/backend/src/modules/mail/mail-events.controller.ts index 2259838..5665e09 100644 --- a/backend/src/modules/mail/mail-events.controller.ts +++ b/backend/src/modules/mail/mail-events.controller.ts @@ -8,7 +8,10 @@ import { } from '@nestjs/swagger'; import type { Response } from 'express'; -import { ApiErrorResponse } from '@/common/decorators/api-standard-response.decorator'; +import { + ApiErrorResponse, + ApiSessionErrors, +} from '@/common/decorators/api-standard-response.decorator'; import { CurrentUser, type AuthenticatedUser, @@ -61,6 +64,7 @@ export function eventStreamSink(res: Response): MailEventSink { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) +@ApiSessionErrors() @Controller('mail-events') export class MailEventsController { constructor(private readonly events: MailEventsService) {} diff --git a/backend/src/modules/mail/mail-filter.controller.ts b/backend/src/modules/mail/mail-filter.controller.ts index 10693be..e0fa4fa 100644 --- a/backend/src/modules/mail/mail-filter.controller.ts +++ b/backend/src/modules/mail/mail-filter.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -46,7 +47,7 @@ const APPLY_NOTE = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(MAIL_FILTERS_ROUTE) export class MailFilterController { @@ -106,7 +107,7 @@ export class MailFilterController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(MAIL_FILTER_RUNS_ROUTE) export class MailFilterRunController { constructor(private readonly mail: MailService) {} diff --git a/backend/src/modules/mail/mail-large-files.service.spec.ts b/backend/src/modules/mail/mail-large-files.service.spec.ts index 64e9543..00249c4 100644 --- a/backend/src/modules/mail/mail-large-files.service.spec.ts +++ b/backend/src/modules/mail/mail-large-files.service.spec.ts @@ -11,6 +11,7 @@ import type { FileScanService, } from '@/modules/file-storage/file-scan.service'; import type { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import type { SystemMailService } from '@/modules/system-mail/system-mail.service'; import type { FileUseProblem, StoredFileView, @@ -161,6 +162,9 @@ function setup() { }), }; const events = { notify: jest.fn() }; + const systemMail = { + notifyDangerousFile: jest.fn().mockResolvedValue('notice-1'), + }; const provider = { getMessage: jest.fn(), getMessageSubjects: jest.fn().mockResolvedValue(new Map()), @@ -171,6 +175,7 @@ function setup() { scans as unknown as FileScanService, events as unknown as MailEventsService, provider as unknown as MailProvider, + systemMail as unknown as SystemMailService, ); service.onModuleInit(); return { @@ -179,10 +184,17 @@ function setup() { sharing, events, provider, + systemMail, verdict: (event: Parameters[0]) => listener?.(event), }; } +/** 판정 소식의 크기 · 올린 때 (13단계에 더한 칸) */ +const UPLOADED = { + size: 50_538_291, + uploadedAt: new Date('2026-10-30T06:58:00Z'), +}; + async function codeOf(run: () => Promise): Promise { try { await run(); @@ -579,12 +591,14 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = fileId: FILE_A, name: '견적서.zip', scanStatus: 'infected', + ...UPLOADED, }); verdict({ ownerEmail: ARA.email, fileId: FILE_B, name: '도면.dwg', scanStatus: 'clean', + ...UPLOADED, }); expect(events.notify).toHaveBeenCalledTimes(1); expect(events.notify).toHaveBeenCalledWith(ARA.email, { @@ -593,4 +607,28 @@ describe('MailLargeFilesService — 메일과 대용량 첨부 (11단계)', () = }); }); }); + + it('위험 판정이면 받은편지함 알림 메일도 넣는다 — 파일 이름 · 크기 · 올린 때 (13단계, 깨끗함은 넣지 않는다)', () => { + const { systemMail, verdict } = setup(); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_A, + name: '견적서.zip', + scanStatus: 'infected', + ...UPLOADED, + }); + verdict({ + ownerEmail: ARA.email, + fileId: FILE_B, + name: '도면.dwg', + scanStatus: 'clean', + ...UPLOADED, + }); + expect(systemMail.notifyDangerousFile).toHaveBeenCalledTimes(1); + expect(systemMail.notifyDangerousFile).toHaveBeenCalledWith(ARA.email, { + name: '견적서.zip', + size: UPLOADED.size, + uploadedAt: UPLOADED.uploadedAt, + }); + }); }); diff --git a/backend/src/modules/mail/mail-large-files.service.ts b/backend/src/modules/mail/mail-large-files.service.ts index b0d59ef..6f4d9a6 100644 --- a/backend/src/modules/mail/mail-large-files.service.ts +++ b/backend/src/modules/mail/mail-large-files.service.ts @@ -29,6 +29,7 @@ import type { MissingLargeFile, StoredFileView, } from '@/modules/file-storage/file-storage.types'; +import { SystemMailService } from '@/modules/system-mail/system-mail.service'; import { traceTag } from '@/shared/logger/request-context'; import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; import { maskEmail } from '@/shared/utils/mask'; @@ -131,7 +132,7 @@ function unusableFiles( /** * 메일과 대용량 첨부(11단계 — 계약 4절 "발송 · 임시 저장과 함께" · "우리 웹메일에서 읽기" · "보낸 사람 관리")를 잇는다. * 파일은 파일 모듈, 링크는 공유 모듈이 맡고 — 메일 → 공유 → 저장 한 방향 — 이 서비스는 메일 쪽 규칙(덩이 · 자리 · 머리글 · 보낸 사람)만 둔다. - * 위험 판정 소식을 실시간 알림(`large-file`)으로 보낸다. + * 위험 판정 소식을 실시간 알림(`large-file`)으로 보내고, 받은편지함 알림 메일도 넣는다(13단계 — 그때 웹메일을 열어 두지 않은 직원도 알게). */ @Injectable() export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { @@ -144,6 +145,8 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { private readonly scans: FileScanService, private readonly events: MailEventsService, @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + /** 13단계 — 위험 판정의 받은편지함 알림 메일 */ + private readonly systemMail: SystemMailService, ) {} onModuleInit(): void { @@ -158,6 +161,12 @@ export class MailLargeFilesService implements OnModuleInit, OnModuleDestroy { scanStatus: 'infected', }, }); + // 받은편지함 알림 메일 (계약 6절 — 파일마다 한 번: 검사 차례가 위험 판정을 처음 적을 때만 이 소식을 보낸다). 넣지 못해도 던지지 않는다 + void this.systemMail.notifyDangerousFile(event.ownerEmail, { + name: event.name, + size: event.size, + uploadedAt: event.uploadedAt, + }); }); } diff --git a/backend/src/modules/mail/mail.module.ts b/backend/src/modules/mail/mail.module.ts index c4258bd..46f83be 100644 --- a/backend/src/modules/mail/mail.module.ts +++ b/backend/src/modules/mail/mail.module.ts @@ -12,6 +12,7 @@ import { FileSharingModule } from '@/modules/file-sharing/file-sharing.module'; import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module'; import { MailSignatureModule } from '@/modules/mail-signature/mail-signature.module'; import { MailUserSettingsModule } from '@/modules/mail-user-settings/mail-user-settings.module'; +import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; import { isAllowedAttachment } from './attachment.policy'; import { DraftController } from './draft.controller'; import { MailImageController } from './images/mail-image.controller'; @@ -124,6 +125,8 @@ function attachmentUploadOptions(config: ConfigService): MulterModuleOptions { FileSharingModule, // 끊은 세션 기억 (12단계) — 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝낸다. 이 모듈은 메일을 가져오지 않는다 AuthSessionModule, + // 받은편지함 알림 메일 (13단계) — 위험 파일 판정을 그 직원의 받은편지함에. 이 모듈은 메일을 가져오지 않는다 + SystemMailModule, ], controllers: [ MailboxController, diff --git a/backend/src/modules/mail/mail.provider.ts b/backend/src/modules/mail/mail.provider.ts index 6c3885f..36150a8 100644 --- a/backend/src/modules/mail/mail.provider.ts +++ b/backend/src/modules/mail/mail.provider.ts @@ -64,6 +64,34 @@ export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); */ export class SendNotStartedException extends BusinessException {} +/** + * 그 직원의 비밀번호가 만료됐다 (13단계 — 계약 6절 "비밀번호 바꾸기 강제") — 엔진은 그 계정의 권한을 비밀번호 바꾸기만으로 줄여 메일 기능이 없다 + * (본인 Basic · 게이트웨이 대리 접근 모두 — 2026-11-02 개발 엔진 확인). 프로필 · 메일 호출(엔진 세션을 열거나 메일 메서드가 forbidden)이 이 예외로 던진다. + * 인증 모듈은 로그인 · 갱신에서 이것을 받아 세션에 passwordChange 제한을 붙인다 — 형식으로 가른다(context 는 로그 전용, 갱신은 세션을 끊지 않는다). + * 메일 API 의 응답으로 나가면 **401 AUTH_001** 이다 — 화면은 곧 갱신하고, 세션이 끊겼으면 세션 만료, 아직이면 갱신이 만료를 알아채 새 비밀번호 단계로 간다 + * (운영 도구의 재설정 직후 끊은 세션이 막히기 전 15초 동안 빈 칸 · 503 이 보이지 않게). + * `account` 는 엔진이 알려 준 정식 주소, `displayName` 은 메일 기능 없이 쓸 표시 이름(주소의 @ 앞 — 표시 이름은 메일 기능으로만 읽는다) + */ +export class PasswordExpiredException extends BusinessException { + constructor( + readonly account: string, + readonly displayName: string, + ) { + super('AUTH_001', { context: { reason: 'password-expired' } }); + } +} + +/** + * 엔진이 그 계정의 대리 접근을 막았다(403)인데 게이트웨이는 대리 접근할 수 있다 — 그 계정에 로그인 권한(authenticate)이 없다(잠김) (13단계). + * 게이트웨이 설정 문제와 가르는 것은 어댑터다(게이트웨이 자신의 권한에 impersonate 가 있는가). 응답으로 나가면 지금처럼 503 MAIL_001 이고 + * 로그인만 이것을 AUTH_004 로 바꾼다 — 2단계를 켠 잠긴 계정이 맞는 비밀번호에만 503 을 받아 비밀번호가 맞는지 바깥에서 알 수 있던 길(실사용 시험) + */ +export class AccountLoginDisabledException extends BusinessException { + constructor(readonly account: string) { + super('MAIL_001', { context: { reason: 'account-login-disabled' } }); + } +} + export interface MailProvider { /** * 직원 자격 증명을 메일 엔진에 확인한다 (CredentialCheck — 맞음 · 틀림 · 2단계 인증 코드 필요). 비밀번호는 저장하지 않는다. diff --git a/backend/src/modules/mail/mail.settings.ts b/backend/src/modules/mail/mail.settings.ts index d5f3814..0e34ca8 100644 --- a/backend/src/modules/mail/mail.settings.ts +++ b/backend/src/modules/mail/mail.settings.ts @@ -137,6 +137,21 @@ export function buildMailSettings( }; } +/** + * 메일 엔진 관리자 주소 — `@` (소문자, 도메인이 없으면 null). + * 운영 도구(13단계)가 관리 API 에 들어갈 때 쓴다 — 막는 목록(blockedAccounts)에 넣는 것과 같은 값이다 + */ +export function buildMailAdminAccount( + read: (key: string) => string | undefined, +): string | null { + const domain = readTrimmed(read, 'MAIL_DOMAIN')?.toLowerCase() ?? null; + if (domain === null) return null; + const local = ( + readTrimmed(read, 'MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_MAIL_ADMIN_LOCAL_PART + ).toLowerCase(); + return `${local}@${domain}`; +} + /** 콤마로 나열한 주소 목록 → 소문자 주소 (형식 검사는 env 검증이 한다) */ export function parseAccountList(raw: string | undefined): string[] { return (raw ?? '') diff --git a/backend/src/modules/mail/mailbox.controller.ts b/backend/src/modules/mail/mailbox.controller.ts index 07f29ca..51a8c7f 100644 --- a/backend/src/modules/mail/mailbox.controller.ts +++ b/backend/src/modules/mail/mailbox.controller.ts @@ -15,6 +15,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -63,8 +64,7 @@ const BULK_NO_PROGRESS = @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @@ -200,7 +200,7 @@ export class MailboxController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller('mail-vacation') export class MailVacationController { @@ -252,7 +252,7 @@ export class MailVacationController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller('mail-quota') export class MailQuotaController { @@ -276,7 +276,7 @@ export class MailQuotaController { @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse( 'MAIL_001', '메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)', diff --git a/backend/src/modules/mail/message-deletion.controller.ts b/backend/src/modules/mail/message-deletion.controller.ts index 9509ebd..109a922 100644 --- a/backend/src/modules/mail/message-deletion.controller.ts +++ b/backend/src/modules/mail/message-deletion.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -27,8 +28,7 @@ import { MailService } from './mail.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller('message-deletions') diff --git a/backend/src/modules/mail/message.controller.ts b/backend/src/modules/mail/message.controller.ts index a792de8..e1d22b1 100644 --- a/backend/src/modules/mail/message.controller.ts +++ b/backend/src/modules/mail/message.controller.ts @@ -29,6 +29,7 @@ import type { Request, Response } from 'express'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -83,8 +84,7 @@ const LARGE_FILE_THROTTLE_LIMIT = 300; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse( - 'AUTH_001', +@ApiSessionErrors( '로그인 필요 (쿠키 없음·만료) — 클라이언트는 /auth/refresh 를 한 번 시도한다', ) @Controller(MESSAGES_ROUTE) diff --git a/backend/src/modules/mail/recipient-suggestion.controller.ts b/backend/src/modules/mail/recipient-suggestion.controller.ts index cc429c1..2b88aa4 100644 --- a/backend/src/modules/mail/recipient-suggestion.controller.ts +++ b/backend/src/modules/mail/recipient-suggestion.controller.ts @@ -3,6 +3,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -18,7 +19,7 @@ import { RecipientSuggestionService } from './recipient-suggestions.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('recipient-suggestions') export class RecipientSuggestionController { constructor(private readonly suggestions: RecipientSuggestionService) {} diff --git a/backend/src/modules/mail/scheduled-message.controller.ts b/backend/src/modules/mail/scheduled-message.controller.ts index e325632..2a54fcc 100644 --- a/backend/src/modules/mail/scheduled-message.controller.ts +++ b/backend/src/modules/mail/scheduled-message.controller.ts @@ -10,6 +10,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -30,7 +31,7 @@ import { ScheduledMessagesService } from './scheduled-messages.service'; @ApiTags('Mail') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @ApiErrorResponse('MAIL_001', '메일 엔진 장애') @Controller(SCHEDULED_MESSAGES_ROUTE) export class ScheduledMessageController { diff --git a/backend/src/modules/mail/share-link.controller.ts b/backend/src/modules/mail/share-link.controller.ts index 5ece335..c408031 100644 --- a/backend/src/modules/mail/share-link.controller.ts +++ b/backend/src/modules/mail/share-link.controller.ts @@ -12,6 +12,7 @@ import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { @@ -39,7 +40,7 @@ export const SHARE_LINKS_ROUTE = 'share-links'; @ApiTags('Files') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller(SHARE_LINKS_ROUTE) export class ShareLinkController { constructor(private readonly largeFiles: MailLargeFilesService) {} diff --git a/backend/src/modules/ops/account-commands.ts b/backend/src/modules/ops/account-commands.ts new file mode 100644 index 0000000..7a62447 --- /dev/null +++ b/backend/src/modules/ops/account-commands.ts @@ -0,0 +1,446 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { SystemMailService } from '@/modules/system-mail/system-mail.service'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { twoFactorRequirementFor } from '@/modules/system-settings/system-settings.types'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsDataService, totalRows } from './ops-data.service'; +import { + OPS_ENGINE, + OpsEngineError, + type EngineEmployee, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + askAddress, + askEmployee, + confirmPlan, + failureLine, + showTempPassword, + step, +} from './ops-prompts'; +import { + bytesText, + failureDetail, + isUnknownOutcome, + toDisplayName, + toQuotaBytes, +} from './ops-text'; +import { generateTempPassword } from './temp-password'; + +/** 임시 비밀번호를 엔진이 약하다고 받지 않을 때 새로 만들어 다시 하는 횟수 */ +const WEAK_PASSWORD_RETRIES = 2; + +/** + * 운영 도구 — 계정 명령 (13단계 — 계약 6절 "운영 도구"): 입사 · 비밀번호 재설정 · 2단계 해제. + * 묻고 → 바뀔 것을 보이고 → y/N → 차례대로 하고 → 실행 기록(ops_audit_log)을 남긴다. 임시 비밀번호는 한 번만 보이고 어디에도 남기지 않는다 + */ +@Injectable() +export class AccountCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly systemMail: SystemMailService, + private readonly settings: SystemSettingsService, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + /** 입사 — 엔진 계정(직원 역할) + 임시 비밀번호(무작위 · 한 번만) + 만료(첫 로그인 때 바꾸기) + 메일함 용량 */ + async hire(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '새 직원 주소'); + if (email === null) return; + if (!this.engine.isEmployeeAddress(email)) { + io.print( + '그 주소는 직원 주소로 쓸 수 없습니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정의 이름입니다.', + ); + return; + } + try { + if (await this.engine.addressInUse(email)) { + io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).'); + return; + } + } catch (error) { + io.print(failureLine(error)); + return; + } + const name = toDisplayName(await io.ask('이름 (메일에 보이는 이름): ')); + if (name === null) { + io.print('이름은 한 줄 1~100자로 넣어 주세요.'); + return; + } + const quota = toQuotaBytes( + await io.ask('메일함 용량 GB (빈 값이면 제한 없음): '), + ); + if (quota === 'invalid') { + io.print('용량은 0 보다 큰 숫자(GB, 소수 한 자리까지)로 넣어 주세요.'); + return; + } + + // 같은 주소를 쓰던 옛 직원의 기록이 남았으면 먼저 지운다 — 새 사람에게 옛 로그인 기록 · 서명 · 2단계 기록 · 2단계 필수 예외가 넘어가지 않게 + const leftoverRows = totalRows(await this.data.countRowsOf(email)); + const leftoverExempt = await this.data.isTwoFactorExempt(email); + const leftoverParts = [ + ...(leftoverRows > 0 ? [`우리 DB 기록 ${leftoverRows}줄`] : []), + ...(leftoverExempt ? ['2단계 인증 필수의 예외 주소'] : []), + ]; + let purgeLeftover = false; + if (leftoverParts.length > 0) { + io.print( + `이 주소에 같은 주소를 쓰던 사람의 것이 남아 있습니다 — ${leftoverParts.join(' · ')}`, + ); + purgeLeftover = await io.confirm('입사 전에 지울까요? (권함)'); + } + + const lines = [ + `주소 ${email}`, + `이름 ${name}`, + `메일함 ${quota === null ? '제한 없음' : bytesText(quota)}`, + '비밀번호 임시 비밀번호(무작위) — 만든 뒤 한 번만 보입니다', + ' 첫 로그인 때 새 비밀번호를 정합니다(엔진의 비밀번호 만료)', + ...(purgeLeftover + ? [`옛 기록 지우기 — ${leftoverParts.join(' · ')}`] + : []), + ]; + if (!(await confirmPlan(io, '입사', lines))) return; + + try { + if (purgeLeftover) { + await this.data.purgeAddress(email); + step(io, true, `옛 기록을 지웠습니다 — ${leftoverParts.join(' · ')}`); + } + const localPart = email.slice(0, email.indexOf('@')); + let password = ''; + for (let attempt = 0; ; attempt += 1) { + password = generateTempPassword(); + try { + await this.engine.createEmployee({ + localPart, + name, + quotaBytes: quota, + tempPassword: password, + }); + break; + } catch (error) { + const weak = + error instanceof OpsEngineError && error.kind === 'weak-password'; + if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; + } + } + step(io, true, '엔진 계정을 만들었습니다.'); + showTempPassword(io, email, password); + await this.audit.record({ + operator, + command: 'hire', + target: email, + result: 'success', + detail: `용량 ${quota === null ? '제한 없음' : bytesText(quota)}${purgeLeftover ? ` · 옛 기록 지움(${leftoverParts.join(' · ')})` : ''}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'hire', + target: email, + result: 'failure', + detail: failureDetail(error), + }); + } + } + + /** 비밀번호 재설정 — 묻고 확인한 뒤 runPasswordReset */ + async resetPassword(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (employee.locked) { + io.print( + '잠긴 계정입니다 — 먼저 「잠금 풀기」를 하세요(잠금 풀기에서 비밀번호 재설정을 함께 할 수 있습니다).', + ); + return; + } + if (!(await confirmPlan(io, '비밀번호 재설정', this.resetPlan(employee)))) { + return; + } + await this.runPasswordReset(io, operator, employee); + } + + /** 비밀번호 재설정의 바뀔 것 — 이미 만료된 계정은 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다(엔진이 막는다) */ + resetPlan(employee: EngineEmployee): string[] { + const expired = employee.passwordExpired; + return [ + `대상 ${employee.name} <${employee.email}>`, + expired + ? `1. 앱 비밀번호 — 건너뜀(비밀번호가 이미 만료된 계정이라 엔진이 막는다)${employee.appPasswords > 0 ? ` · ${employee.appPasswords}개가 남아 있다 — 직원이 새 비밀번호를 정한 뒤 다시 재설정` : ''}` + : `1. 앱 비밀번호 ${employee.appPasswords}개 지우기 (메일 프로그램은 새 앱 비밀번호로 다시 연결)`, + '2. 웹메일 로그인 모두 끊기', + expired + ? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)' + : '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)', + '4. 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다 (그사이 새로 로그인한 세션도 끊는다)', + ` 2단계 인증은 그대로 (${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} — 따로 「2단계 해제」)`, + ]; + } + + /** + * 비밀번호 재설정의 차례 (계약 6절 — 만료 뒤에는 대리 접근이 막히므로 대리 접근 일을 먼저 한다): + * 1. 앱 비밀번호 지우기(대리 접근) — 지우지 못하면 아무것도 바꾸지 않고 멈춘다(비밀번호가 샌 동안 만든 앱 비밀번호가 남지 않게) + * 2. 웹메일 세션 끊기(DB) — 알림 메일이 "끊었습니다"라고 말하기 전에 끝내 둔다. 끊지 못하면 멈춘다 + * 3. 받은편지함 알림(대리 접근) — 넣지 못해도 이어 간다 + * 4. 엔진에 임시 비밀번호 + 만료 — 실패하면 넣은 알림을 지운다(아직 만료 전이라 지울 수 있다) + * 5. 세션 한 번 더 끊기 — 2와 4 사이에 옛 비밀번호로 새로 로그인한 세션 + * 이미 만료된 계정(지난 재설정 뒤 아직 바꾸지 않음)은 1 · 3 을 건너뛴다 — 엔진이 대리 접근을 막는다 + */ + async runPasswordReset( + io: OpsIo, + operator: string, + employee: EngineEmployee, + ): Promise { + const notes: string[] = []; + const fail = async (detail: string): Promise => { + await this.audit.record({ + operator, + command: 'password-reset', + target: employee.email, + result: 'failure', + detail: [...notes, detail].join(' · '), + }); + }; + + // 1. 앱 비밀번호 + if (employee.passwordExpired) { + step( + io, + employee.appPasswords === 0, + employee.appPasswords === 0 + ? '앱 비밀번호 — 비밀번호가 이미 만료된 계정이라 건너뜁니다(만들 수 없었습니다).' + : `앱 비밀번호 ${employee.appPasswords}개 — 비밀번호가 만료된 동안은 엔진이 지우기를 막습니다. 직원이 새 비밀번호를 정한 뒤 다시 재설정하세요.`, + ); + notes.push('앱 비밀번호 건너뜀(이미 만료)'); + } else { + try { + const removed = await this.engine.deleteAppPasswords(employee.email); + step(io, true, `앱 비밀번호 ${removed}개를 지웠습니다.`); + notes.push(`앱 비밀번호 ${removed}개 지움`); + } catch (error) { + step( + io, + false, + `앱 비밀번호를 지우지 못해 멈춥니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`, + ); + await fail(`앱 비밀번호 지우기 실패 · ${failureDetail(error)}`); + return; + } + } + + // 2. 웹메일 세션 끊기 — 서버는 15초 안에 막는다 + let ended: number; + try { + ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + } catch (error) { + step( + io, + false, + `웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`, + ); + await fail(`세션 끊기 실패 · ${failureDetail(error)}`); + return; + } + + // 3. 받은편지함 알림 — 넣지 못해도 이어 간다. 만료된 계정에는 넣을 수 없다(계약 6절) + let noticeId: string | null = null; + if (employee.passwordExpired) { + step( + io, + true, + '받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.', + ); + notes.push('알림 메일 건너뜀(이미 만료)'); + } else { + noticeId = await this.systemMail.notifyPasswordReset(employee.email, { + name: employee.name, + }); + step( + io, + noticeId !== null, + noticeId !== null + ? '받은편지함 알림 메일을 넣었습니다.' + : '받은편지함 알림 메일을 넣지 못했습니다 — 재설정은 이어 갑니다.', + ); + notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음'); + } + + // 4. 엔진 — 임시 비밀번호 + 만료 + let password = ''; + try { + for (let attempt = 0; ; attempt += 1) { + password = generateTempPassword(); + try { + await this.engine.resetPassword(employee.id, password); + break; + } catch (error) { + const weak = + error instanceof OpsEngineError && error.kind === 'weak-password'; + if (!weak || attempt >= WEAK_PASSWORD_RETRIES) throw error; + } + } + step(io, true, '임시 비밀번호와 만료를 걸었습니다.'); + } catch (error) { + notes.push(`세션 ${ended}개 끊음`); + if (isUnknownOutcome(error)) { + // 엔진이 바꿨을 수 있다 — 알림 메일은 그대로 둔다(바뀌었다면 사실이다). 다시 하면 새 임시 비밀번호를 만든다 + step( + io, + false, + `비밀번호를 바꿨는지 모릅니다(웹메일 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`, + ); + await fail(`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`); + return; + } + step( + io, + false, + `비밀번호를 바꾸지 못했습니다(웹메일 로그인은 끊었습니다). ${failureLine(error)}`, + ); + if (noticeId !== null) { + const removed = await this.systemMail.remove(employee.email, noticeId); + step( + io, + removed, + removed + ? '넣었던 알림 메일을 지웠습니다.' + : '넣었던 알림 메일을 지우지 못했습니다 — 직원에게 무시하라고 알려 주세요.', + ); + } + await fail(`비밀번호 바꾸기 실패 · ${failureDetail(error)}`); + return; + } + + // 5. 그사이 옛 비밀번호로 새로 로그인한 세션 + let result: 'success' | 'partial' = 'success'; + try { + const late = await this.data.revokeSessions(employee.email); + ended += late; + if (late > 0) { + step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`); + } + } catch (error) { + result = 'partial'; + step( + io, + false, + `그사이 새로 로그인한 세션을 확인하지 못했습니다 — 걱정되면 다시 「비밀번호 재설정」을 하세요. ${failureLine(error)}`, + ); + notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`); + } + notes.push(`세션 ${ended}개 끊음`); + + showTempPassword(io, employee.email, password); + await this.audit.record({ + operator, + command: 'password-reset', + target: employee.email, + result, + detail: notes.join(' · '), + }); + } + + /** 2단계 해제 — 휴대폰 분실: 엔진 OTP 지우기 + 켠 때 기록 정리 + 받은편지함 알림 */ + async resetTwoFactor(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (!employee.twoFactorEnabled) { + io.print('그 직원은 2단계 인증이 꺼져 있습니다 — 할 것이 없습니다.'); + return; + } + const requirement = twoFactorRequirementFor( + (await this.settings.reload()).twoFactor, + employee.email, + new Date(), + ); + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + '1. 엔진의 2단계 인증(OTP) 지우기 — 지금은 비밀번호만으로 로그인한다', + '2. 웹메일의 2단계 인증 기록 정리', + employee.passwordExpired + ? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)' + : '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)', + ' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저', + ...(requirement.isRequired + ? [ + ' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다', + ] + : []), + ]; + if (!(await confirmPlan(io, '2단계 해제', lines))) return; + + try { + await this.engine.removeTwoFactor(employee.id); + step(io, true, '엔진의 2단계 인증을 지웠습니다.'); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'two-factor-reset', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + const notes: string[] = []; + let result: 'success' | 'partial' = 'success'; + try { + await this.data.forgetTwoFactor(employee.email); + step(io, true, '웹메일의 2단계 인증 기록을 정리했습니다.'); + } catch (error) { + // 엔진이 기준이다 — 다음 상태 읽기가 기록을 다시 맞춘다 + result = 'partial'; + step( + io, + false, + `기록을 정리하지 못했습니다(다음 상태 읽기가 맞춥니다). ${failureLine(error)}`, + ); + notes.push(`기록 정리 실패 · ${failureDetail(error)}`); + } + if (employee.passwordExpired) { + step( + io, + true, + '받은편지함 알림 메일 — 비밀번호가 만료된 계정이라 건너뜁니다.', + ); + notes.push('알림 메일 건너뜀(이미 만료)'); + } else { + const noticeId = await this.systemMail.notifyTwoFactorReset( + employee.email, + { name: employee.name }, + ); + step( + io, + noticeId !== null, + noticeId !== null + ? '받은편지함 알림 메일을 넣었습니다.' + : '받은편지함 알림 메일을 넣지 못했습니다.', + ); + notes.push(noticeId !== null ? '알림 메일 넣음' : '알림 메일 못 넣음'); + } + await this.audit.record({ + operator, + command: 'two-factor-reset', + target: employee.email, + result, + detail: notes.join(' · '), + }); + } +} diff --git a/backend/src/modules/ops/admin-commands.ts b/backend/src/modules/ops/admin-commands.ts new file mode 100644 index 0000000..2a0c34a --- /dev/null +++ b/backend/src/modules/ops/admin-commands.ts @@ -0,0 +1,557 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + checkSupportContact, + type SystemSettings, +} from '@/modules/system-settings/system-settings.types'; +import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsDataService } from './ops-data.service'; +import { + OPS_ENGINE, + type EngineEmployee, + type EngineMailingList, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + RULE, + askAddress, + confirmPlan, + failureLine, + logUnknownFailure, + step, +} from './ops-prompts'; +import { + bytesText, + failureDetail, + timeText, + toAddress, + toDisplayName, +} from './ops-text'; + +/** 공용 주소 하나의 구성원 상한 — 오타로 아주 긴 목록을 만들지 않게 */ +const MAILING_LIST_MAX_MEMBERS = 200; + +/** 2단계 인증 필수 정책을 보이는 글 */ +export function policyText(settings: SystemSettings): string { + const { twoFactor } = settings; + if (!twoFactor.required) return '꺼짐'; + const start = twoFactor.startDate ?? '켠 때부터'; + const exempt = twoFactor.exempt.length; + return `켜짐 · 시작일 ${start}${exempt > 0 ? ` · 예외 ${exempt}명` : ''}`; +} + +/** 현황의 한 줄 */ +export function employeeLine( + employee: EngineEmployee, + lastLogin: Date | undefined, +): string { + const usage = + employee.quotaBytes === null + ? bytesText(employee.usedBytes) + : `${bytesText(employee.usedBytes)} / ${bytesText(employee.quotaBytes)}`; + const flags = [ + employee.locked ? '잠김' : null, + employee.passwordExpired ? '임시 비밀번호' : null, + ] + .filter(Boolean) + .join(' · '); + return [ + `${employee.email} (${employee.name})`, + ` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`, + ` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usage}`, + ].join('\n'); +} + +/** + * 운영 도구 — 관리 명령 (13단계 — 계약 6절 "운영 도구"): 링크 끊기 · 공용 주소 · 현황 · 정책 · 설정. + * 바꾸는 명령은 실행 기록(ops_audit_log)을 남긴다. 신고된 링크의 주소(토큰이 들어 있다)는 보이지도 남기지도 않는다 + */ +@Injectable() +export class AdminCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly sharing: FileSharingService, + private readonly settings: SystemSettingsService, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + /** 링크 끊기 — 신고된 공개 링크 주소로 → revoked_by = operator */ + async revokeLink(io: OpsIo, operator: string): Promise { + const url = await io.ask('신고된 공개 링크 주소 (https://…/s/…): '); + // 우리 링크가 아니거나 없으면 null — DB 오류는 삼키지 않는다(그것을 "우리 링크 아님"으로 보이면 신고를 놓친다) + let record: Awaited>; + try { + record = await this.sharing.findForOperator(url); + } catch (error) { + io.print(failureLine(error)); + return; + } + if (record === null) { + io.print('우리 공개 링크 주소가 아니거나 없는(취소한) 링크입니다.'); + return; + } + const { link, files } = record; + if (link.revokedAt !== null) { + io.print( + `이미 끊긴 링크입니다 (${timeText(link.revokedAt)} · ${link.revokedBy === 'operator' ? '운영자' : '보낸 사람'}).`, + ); + return; + } + const lines = [ + `보낸 사람 ${link.senderName ?? ''} <${link.ownerEmail}>`, + `보낸 때 ${timeText(link.sentAt)} · 끝나는 때 ${timeText(link.expiresAt)}`, + `파일 ${files.length}개`, + ...files.map( + (file) => + ` ${file.name} (${bytesText(file.size)}) · 받은 수 ${file.downloadCount}`, + ), + '끊으면 받는 사람이 더 내려받지 못한다(되돌릴 수 없다)', + ]; + if (!(await confirmPlan(io, '링크 끊기', lines))) return; + try { + const revoked = await this.sharing.revokeAsOperator(link.id); + step( + io, + revoked, + revoked + ? '링크를 끊었습니다.' + : '끊지 않았습니다 — 그사이 끊겼거나 기간이 지났습니다.', + ); + await this.audit.record({ + operator, + command: 'link-revoke', + target: link.ownerEmail, + result: revoked ? 'success' : 'failure', + detail: `link=${link.id} · 파일 ${files.length}개${revoked ? '' : ' · 이미 끝난 링크'}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'link-revoke', + target: link.ownerEmail, + result: 'failure', + detail: `link=${link.id} · ${failureDetail(error)}`, + }); + } + } + + /** 공용 주소 — 목록 · 만들기 · 구성원 넣기 · 빼기 */ + async mailingLists(io: OpsIo, operator: string): Promise { + io.print( + '공용 주소 — 1. 목록 2. 만들기 3. 구성원 넣기 4. 구성원 빼기 0. 돌아가기', + ); + const choice = await io.ask('고르세요: '); + try { + if (choice === '1') await this.printMailingLists(io); + else if (choice === '2') await this.createMailingList(io, operator); + else if (choice === '3') await this.changeMember(io, operator, true); + else if (choice === '4') await this.changeMember(io, operator, false); + } catch (error) { + io.print(failureLine(error)); + } + } + + private async printMailingLists(io: OpsIo): Promise { + const lists = await this.engine.listMailingLists(); + if (lists.length === 0) { + io.print('공용 주소가 없습니다.'); + return; + } + for (const list of lists) { + io.print( + `${list.email} (${list.name}) — 구성원 ${list.members.length}명`, + ); + for (const member of list.members) io.print(` ${member}`); + } + } + + private async createMailingList(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '새 공용 주소'); + if (email === null) return; + if (await this.engine.addressInUse(email)) { + io.print('그 주소는 엔진에서 이미 쓰고 있습니다 (계정 · 공용 주소).'); + return; + } + const name = toDisplayName(await io.ask('이름 (예: 대표 문의): ')); + if (name === null) { + io.print('이름은 한 줄 1~100자로 넣어 주세요.'); + return; + } + const members = await this.askMembers(io); + if (members === null) return; + const lines = [ + `주소 ${email}`, + `이름 ${name}`, + `구성원 ${members.join(', ')}`, + '이 주소로 온 메일은 구성원 각자의 받은편지함에 사본으로 들어간다', + ]; + if (!(await confirmPlan(io, '공용 주소 만들기', lines))) return; + try { + await this.engine.createMailingList({ + localPart: email.slice(0, email.indexOf('@')), + name, + members, + }); + step(io, true, '공용 주소를 만들었습니다.'); + await this.audit.record({ + operator, + command: 'mailing-list', + target: email, + result: 'success', + detail: `만들기 · 구성원 ${members.length}명`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'mailing-list', + target: email, + result: 'failure', + detail: `만들기 · ${failureDetail(error)}`, + }); + } + } + + /** 구성원 — 쉼표로 나눈 사내 주소(엔진에 있는 직원) 1명 이상 */ + private async askMembers(io: OpsIo): Promise { + const input = await io.ask('구성원 주소 (쉼표로 나눠 여러 명): '); + const members = [ + ...new Set( + input + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + .map((part) => toAddress(part, this.domain)), + ), + ]; + if ( + members.length === 0 || + members.length > MAILING_LIST_MAX_MEMBERS || + members.some((member) => member === null) + ) { + io.print( + `구성원은 @${this.domain} 주소로 1~${MAILING_LIST_MAX_MEMBERS}명 넣어 주세요.`, + ); + return null; + } + const addresses = members as string[]; + for (const address of addresses) { + if (!(await this.engine.findEmployee(address))) { + io.print(`${address} — 엔진에 그 직원 계정이 없습니다.`); + return null; + } + } + return addresses; + } + + private async changeMember( + io: OpsIo, + operator: string, + include: boolean, + ): Promise { + const listEmail = await askAddress(io, this.domain, '공용 주소'); + if (listEmail === null) return; + const list = (await this.engine.listMailingLists()).find( + (item: EngineMailingList) => item.email === listEmail, + ); + if (!list) { + io.print('그런 공용 주소가 없습니다.'); + return; + } + const member = await askAddress( + io, + this.domain, + include ? '넣을 구성원 주소' : '뺄 구성원 주소', + ); + if (member === null) return; + if (include && list.members.includes(member)) { + io.print('이미 구성원입니다.'); + return; + } + if (!include && !list.members.includes(member)) { + io.print('구성원이 아닙니다.'); + return; + } + if (include && !(await this.engine.findEmployee(member))) { + io.print('엔진에 그 직원 계정이 없습니다.'); + return; + } + if (!include && list.members.length === 1) { + io.print( + '마지막 구성원은 뺄 수 없습니다 — 공용 주소에는 구성원이 한 명 이상 있어야 합니다.', + ); + return; + } + const action = include ? '구성원 넣기' : '구성원 빼기'; + if ( + !(await confirmPlan(io, action, [ + `공용 주소 ${list.email} (${list.name})`, + `${include ? '넣을' : '뺄'} 사람 ${member}`, + ])) + ) { + return; + } + try { + await this.engine.setMailingListMember(list.id, member, include); + step(io, true, `${action}를 했습니다.`); + await this.audit.record({ + operator, + command: 'mailing-list', + target: list.email, + result: 'success', + detail: `${action} · ${member}`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'mailing-list', + target: list.email, + result: 'failure', + detail: `${action} · ${member} · ${failureDetail(error)}`, + }); + } + } + + /** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 (읽기만 — 기록하지 않는다) */ + async status(io: OpsIo): Promise { + try { + const [employees, settings] = await Promise.all([ + this.engine.listEmployees(), + this.settings.reload(), + ]); + const lastLogins = await this.data.lastWebmailLogins( + employees.map((employee) => employee.email), + ); + io.print(''); + io.print(RULE); + io.print(` 직원 ${employees.length}명`); + io.print(RULE); + for (const employee of employees) { + io.print(employeeLine(employee, lastLogins.get(employee.email))); + } + io.print(RULE); + io.print(` 2단계 인증 필수 정책 ${policyText(settings)}`); + io.print( + ` 문의 연락처 ${settings.supportContact ?? '(정하지 않음)'}`, + ); + io.print(RULE); + } catch (error) { + io.print(failureLine(error)); + } + } + + /** 정책 · 설정 — 2단계 인증 필수(켜기 · 시작일 · 예외), 문의 연락처 */ + async policy(io: OpsIo, operator: string): Promise { + const settings = await this.settings.reload(); + io.print(`2단계 인증 필수 정책: ${policyText(settings)}`); + if (settings.twoFactor.exempt.length > 0) { + io.print(` 예외: ${settings.twoFactor.exempt.join(', ')}`); + } + io.print(`문의 연락처: ${settings.supportContact ?? '(정하지 않음)'}`); + io.print( + '1. 2단계 인증 필수 켜기 · 시작일 2. 2단계 인증 필수 끄기 3. 예외 주소 넣기 4. 예외 주소 빼기 5. 문의 연락처 0. 돌아가기', + ); + const choice = await io.ask('고르세요: '); + try { + if (choice === '1') await this.requireTwoFactor(io, operator); + else if (choice === '2') await this.stopRequiringTwoFactor(io, operator); + else if (choice === '3') await this.changeExempt(io, operator, true); + else if (choice === '4') await this.changeExempt(io, operator, false); + else if (choice === '5') await this.setSupportContact(io, operator); + } catch (error) { + io.print(failureLine(error)); + } + } + + private async requireTwoFactor(io: OpsIo, operator: string): Promise { + const today = kstDateString(new Date()); + const input = await io.ask( + `시작일 (YYYY-MM-DD, 한국 날짜 — 그날 0시부터 막는다. 빈 값이면 오늘 ${today}): `, + ); + const startDate = input === '' ? today : input; + if (kstStartOfDate(startDate) === null) { + io.print('날짜는 2026-12-01 처럼 넣어 주세요.'); + return; + } + // 안내의 숫자만 — 읽지 못하면 그 줄을 뺀다(모르는 오류는 로그에) + const employees = await this.engine.listEmployees().catch((error) => { + logUnknownFailure(error); + return null; + }); + const withoutTwoFactor = employees?.filter( + (employee) => !employee.locked && !employee.twoFactorEnabled, + ).length; + const lines = [ + `시작일 ${startDate} 0시(KST) 부터 — 그전에는 웹메일이 안내만 한다`, + '시작일부터 2단계를 켜지 않은 직원은 로그인 뒤 켜는 단계를 먼저 거친다(예외 주소 빼고)', + '이미 로그인한 직원도 다음 갱신(최대 1시간) 때 적용된다', + ...(withoutTwoFactor !== undefined + ? [`지금 2단계를 켜지 않은 직원 ${withoutTwoFactor}명`] + : []), + ...(startDate <= today + ? [ + '주의: 시작일이 오늘이거나 지났다 — 바로 적용된다(로그인한 직원은 다음 갱신 때)', + ] + : []), + ]; + if (!(await confirmPlan(io, '2단계 인증 필수 켜기', lines))) return; + await this.saveSettings( + io, + operator, + 'policy', + '2단계 인증 필수', + { twoFactor: { required: true, startDate } }, + `켜기 · 시작일 ${startDate}`, + ); + } + + private async stopRequiringTwoFactor( + io: OpsIo, + operator: string, + ): Promise { + if ( + !(await confirmPlan(io, '2단계 인증 필수 끄기', [ + '로그인 뒤 켜는 단계 · 웹메일의 안내가 없어진다(이미 켠 2단계 인증은 그대로)', + ])) + ) { + return; + } + await this.saveSettings( + io, + operator, + 'policy', + '2단계 인증 필수', + { twoFactor: { required: false } }, + '끄기', + ); + } + + private async changeExempt( + io: OpsIo, + operator: string, + include: boolean, + ): Promise { + const email = await askAddress( + io, + this.domain, + include ? '예외로 둘 직원 주소' : '예외에서 뺄 직원 주소', + ); + if (email === null) return; + const current = (await this.settings.reload()).twoFactor.exempt; + if (include === current.includes(email)) { + io.print(include ? '이미 예외입니다.' : '예외가 아닙니다.'); + return; + } + if ( + !(await confirmPlan(io, include ? '예외 주소 넣기' : '예외 주소 빼기', [ + `주소 ${email}`, + include + ? '이 직원은 2단계 인증 필수 정책에서 빠진다' + : '이 직원도 2단계 인증 필수 정책을 따른다', + ])) + ) { + return; + } + // y/N 뒤에 서버 설정 줄을 잠그고 그때의 목록에 넣고 뺀다 — 묻는 동안 다른 운영자가 바꾼 것을 잃지 않게 (재리뷰 N5) + const detail = include ? '예외 넣기' : '예외 빼기'; + try { + const changed = await this.data.changeTwoFactorExempt(email, include); + step( + io, + true, + changed + ? '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.' + : `그사이 이미 ${include ? '예외가 되었습니다' : '예외에서 빠졌습니다'} — 바꾼 것이 없습니다.`, + ); + await this.audit.record({ + operator, + command: 'policy', + target: email, + result: 'success', + detail: changed ? detail : `${detail} · 이미 그 상태`, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command: 'policy', + target: email, + result: 'failure', + detail: `${detail} · ${failureDetail(error)}`, + }); + } + } + + private async setSupportContact(io: OpsIo, operator: string): Promise { + const input = await io.ask( + '문의 연락처 (한 줄 200자 — 예: IT 담당 홍길동 · 02-000-0000. 빈 값이면 지운다): ', + ); + const checked = checkSupportContact(input); + if (!checked.ok) { + io.print( + checked.problem === 'too-long' + ? '200자까지 넣을 수 있습니다.' + : '한 줄로 넣어 주세요(줄바꿈 · 탭 없이).', + ); + return; + } + const lines = [ + checked.value === null + ? '연락처를 지운다 — 화면은 지금 글 그대로' + : `연락처 ${checked.value}`, + '로그인 화면 · 2단계 코드 단계 · 받은편지함 알림 메일의 마지막 줄에 글자로만 보인다', + ]; + if (!(await confirmPlan(io, '문의 연락처', lines))) return; + await this.saveSettings( + io, + operator, + 'support-contact', + null, + { supportContact: checked.value }, + checked.value === null ? '지우기' : '정하기', + ); + } + + private async saveSettings( + io: OpsIo, + operator: string, + command: 'policy' | 'support-contact', + target: string | null, + patch: Parameters[0], + detail: string, + ): Promise { + try { + await this.settings.update(patch, new Date()); + step(io, true, '저장했습니다 — 서버는 10초 안에 새 값을 씁니다.'); + await this.audit.record({ + operator, + command, + target, + result: 'success', + detail, + }); + } catch (error) { + step(io, false, failureLine(error)); + await this.audit.record({ + operator, + command, + target, + result: 'failure', + detail: `${detail} · ${failureDetail(error)}`, + }); + } + } +} diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts new file mode 100644 index 0000000..00ce17e --- /dev/null +++ b/backend/src/modules/ops/lock-commands.ts @@ -0,0 +1,680 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { + MAX_VACATION_SUBJECT_LENGTH, + MAX_VACATION_TEXT_LENGTH, +} from '@/modules/mail/mail.constants'; +import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; +import { AccountCommands } from './account-commands'; +import { OpsAuditService } from './ops-audit.service'; +import { + OpsDataService, + totalRows, + type AddressPurge, + type AddressRows, +} from './ops-data.service'; +import { + OPS_ENGINE, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { + NOT_EMPLOYEE_ACCOUNT, + NOT_EMPLOYEE_ADDRESS, + askAddress, + askEmployee, + confirmPlan, + failureLine, + logUnknownFailure, + step, +} from './ops-prompts'; +import { bytesText, failureDetail, isUnknownOutcome } from './ops-text'; + +/** 엔진이 Sieve 문자열에서 늘 변수로 펼치는 글 — 자동 응답에 받지 않는다 (sieve-script.ts 와 같은 규칙) */ +const SIEVE_VARIABLE = '${'; +/** 자동 응답 내용의 한 줄로 받지 않는 제어 문자 (줄바꿈은 따로 잇는다) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; + +/** 표의 이름 — 퇴사 마무리에서 지울 줄을 보일 때 */ +const ROW_LABELS: Record = { + loginEvents: '로그인 기록', + appPasswordUsages: '앱 비밀번호 사용 기록', + twoFactorSettings: '2단계 인증 기록', + signatures: '서명', + signatureImages: '서명 그림', + mailSettings: '보내기 · 읽기 설정', + directory: '사내 목록 기록', + files: '대용량 파일', + shareLinks: '대용량 첨부 링크', +}; + +/** 남은 줄을 보이는 글 */ +export function rowsLines(rows: AddressRows): string[] { + return (Object.keys(ROW_LABELS) as (keyof AddressRows)[]) + .filter((key) => rows[key] > 0) + .map((key) => ` ${ROW_LABELS[key]} ${rows[key]}줄`); +} + +/** + * 퇴사 마무리가 그 주소를 지울 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 (바뀔 것에 보이고 y/N 뒤에 지운다). + * `keptLists` 는 그 직원이 마지막 구성원이라 운영자가 남기기로 한 공용 주소다(재리뷰 N4) + */ +interface AddressFootprint { + readonly rows: AddressRows; + readonly exempt: boolean; + readonly lists: readonly EngineMailingList[]; + readonly keptLists: readonly EngineMailingList[]; +} + +/** 그 직원이 마지막 구성원인가 — 빼면 받는 사람이 없다 */ +function isLastMember(list: EngineMailingList): boolean { + return list.members.length === 1; +} + +function footprintEmpty(footprint: AddressFootprint): boolean { + return ( + totalRows(footprint.rows) === 0 && + !footprint.exempt && + footprint.lists.length === 0 && + footprint.keptLists.length === 0 + ); +} + +/** 퇴사 마무리의 바뀔 것 — 우리 DB · 예외 주소 · 공용 주소 */ +function footprintLines(footprint: AddressFootprint, first: number): string[] { + const lines = [ + `${first}. 우리 DB 의 그 주소 기록 지우기 — 같은 주소를 다시 쓸 때 섞이지 않게`, + ...rowsLines(footprint.rows), + ...(footprint.exempt ? [' 2단계 인증 필수의 예외 주소에서 빼기'] : []), + ' 웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다', + ]; + if (footprint.lists.length > 0) { + lines.push( + `${first + 1}. 공용 주소 ${footprint.lists.length}개의 구성원에서 빼기 — ${footprint.lists.map((list) => list.email).join(', ')}`, + ...footprint.lists + .filter(isLastMember) + .map( + (list) => + ` ${list.email} — 마지막 구성원이다(빼면 받는 사람이 없다)`, + ), + ); + } + lines.push( + ...footprint.keptLists.map( + (list) => + ` 남김: ${list.email} — 마지막 구성원(다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)`, + ), + ' 실행 기록(운영 도구)은 남긴다', + ); + return lines; +} + +/** + * 운영 도구 — 잠금 명령 (13단계 — 계약 6절 "운영 도구"): 잠금 · 잠금 풀기 · 퇴사 마무리. + * - 잠금: 받는 메일 넘기기 · 자동 응답(대리 접근 Sieve)을 먼저 → 로그인 권한(authenticate) 빼기 → 웹메일 세션 끊기 → (묻고) 공개 링크 끊기. + * 뺀 뒤에는 대리 접근이 막혀 Sieve 를 새로 걸 수 없다(이미 건 Sieve 는 계속 돈다). 메일은 계속 받는다 + * - 잠금 풀기: 로그인 권한 되돌리기 → (묻고) 넘기기 · 자동 응답 떼기 → 비밀번호 재설정을 함께 권한다 + * - 퇴사 마무리: 잠긴 계정만 — 주소를 다시 쳐서 확인한 뒤 엔진 계정 지우기(메일 영구 삭제) → 우리 DB 의 그 주소 줄 정리 + */ +@Injectable() +export class LockCommands { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + @Inject(MAIL_SETTINGS) private readonly mailSettings: MailSettings, + private readonly data: OpsDataService, + private readonly audit: OpsAuditService, + private readonly sharing: FileSharingService, + private readonly accounts: AccountCommands, + ) {} + + private get domain(): string { + return this.mailSettings.domain ?? ''; + } + + async lock(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (employee.locked) { + io.print('이미 잠긴 계정입니다.'); + return; + } + if (employee.customPermissions) { + io.print( + '이 계정은 권한이 직접 정해져 있어 이 도구로 잠그지 않습니다 — 엔진 관리 화면에서 하세요.', + ); + return; + } + + const rules = await this.askLockRules(io, employee); + if (rules === null) return; + const revokeLinks = await io.confirm( + '그 직원이 보낸 대용량 첨부 공개 링크도 모두 끊을까요?', + ); + + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + ...(rules.forwardTo !== null + ? [`1. 받는 메일의 사본을 ${rules.forwardTo} 로 넘기기 (원본도 남는다)`] + : []), + ...(rules.autoReply !== null + ? [`2. 자동 응답 — 제목 「${rules.autoReply.subject}」`] + : []), + '3. 로그인 권한 빼기 — 웹메일 · 메일 프로그램 · 앱 비밀번호가 모두 막힌다. 메일은 계속 받는다', + '4. 웹메일 로그인 모두 끊기', + ...(revokeLinks ? ['5. 보낸 대용량 첨부 공개 링크 모두 끊기'] : []), + ]; + if (!(await confirmPlan(io, '잠금', lines))) return; + + const notes: string[] = []; + const sieveRequested = rules.forwardTo !== null || rules.autoReply !== null; + // 1 · 2. Sieve — 로그인 권한을 빼기 전에 (뺀 뒤에는 대리 접근이 막힌다) + if (sieveRequested) { + try { + await this.engine.applyLockRules(employee.email, rules); + step(io, true, '받는 메일 넘기기 · 자동 응답을 걸었습니다.'); + notes.push( + [ + rules.forwardTo !== null ? `넘기기 → ${rules.forwardTo}` : null, + rules.autoReply !== null ? '자동 응답' : null, + ] + .filter(Boolean) + .join(' · '), + ); + } catch (error) { + step( + io, + false, + `넘기기 · 자동 응답을 걸지 못해 멈춥니다 — 잠그지 않았습니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result: 'failure', + detail: `Sieve 실패 · ${failureDetail(error)}`, + }); + return; + } + } + + // 3. 로그인 권한 빼기 + try { + await this.engine.setLoginEnabled(employee.id, false); + step(io, true, '로그인 권한을 뺐습니다.'); + } catch (error) { + const unknown = isUnknownOutcome(error); + step( + io, + false, + unknown + ? `잠갔는지 모릅니다 — ${failureLine(error)}` + : `잠그지 못했습니다(로그인 권한을 빼지 못함). ${failureLine(error)}`, + ); + notes.push( + `잠금 ${unknown ? '결과 모름' : '실패'} · ${failureDetail(error)}`, + ); + // 결과를 모르면 잠겼을 수 있다 — 걸어 둔 넘기기 · 자동 응답은 그대로 둔다(다시 「잠금」을 하면 이어 쓴다) + await this.afterLockFailed( + io, + employee, + sieveRequested && !unknown, + notes, + ); + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result: 'failure', + detail: notes.join(' · '), + }); + return; + } + + // 4 · 5. 세션 · 링크 + let result: 'success' | 'partial' = 'success'; + try { + const ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`, + ); + notes.push(`세션 ${ended}개 끊음`); + } catch (error) { + result = 'partial'; + step(io, false, `웹메일 로그인을 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`세션 끊기 실패 · ${failureDetail(error)}`); + } + if (revokeLinks) { + try { + const revoked = await this.sharing.revokeAllOfOwnerAsOperator( + employee.email, + ); + step(io, true, `공개 링크 ${revoked}개를 끊었습니다.`); + notes.push(`링크 ${revoked}개 끊음`); + } catch (error) { + result = 'partial'; + step(io, false, `공개 링크를 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`링크 끊기 실패 · ${failureDetail(error)}`); + } + } + await this.audit.record({ + operator, + command: 'lock', + target: employee.email, + result, + detail: notes.join(' · ') || null, + }); + } + + /** + * 로그인 권한을 빼지 못했다 — 웹메일 세션은 끊어 둔다(잠그려던 계정이다 — 털림 · 퇴사, 다시 「잠금」을 하면 된다). + * 걸어 둔 넘기기 · 자동 응답은 계정이 그대로 쓰이므로 뗄지 묻는다 + */ + private async afterLockFailed( + io: OpsIo, + employee: EngineEmployee, + sieveApplied: boolean, + notes: string[], + ): Promise { + try { + const ended = await this.data.revokeSessions(employee.email); + step( + io, + true, + `웹메일 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`, + ); + notes.push(`세션 ${ended}개 끊음`); + } catch (error) { + step(io, false, `웹메일 로그인도 끊지 못했습니다. ${failureLine(error)}`); + notes.push(`세션 끊기 실패 · ${failureDetail(error)}`); + } + if ( + !sieveApplied || + !(await io.confirm( + '걸어 둔 받는 메일 넘기기 · 자동 응답을 뗄까요? (잠그지 못해 계정은 그대로 쓰입니다)', + )) + ) { + return; + } + try { + await this.engine.clearLockRules(employee.email); + step(io, true, '넘기기 · 자동 응답을 뗐습니다.'); + notes.push('Sieve 뗌'); + } catch (error) { + step( + io, + false, + `떼지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`, + ); + notes.push(`Sieve 떼기 실패 · ${failureDetail(error)}`); + } + } + + /** 넘기기 · 자동 응답을 묻는다 — 잘못 넣으면 null(잠그지 않는다) */ + private async askLockRules( + io: OpsIo, + employee: EngineEmployee, + ): Promise { + let forwardTo: string | null = null; + if (await io.confirm('받는 메일의 사본을 동료에게 넘길까요?')) { + const target = await askEmployee( + io, + this.engine, + this.domain, + '받을 동료 주소', + ); + if (target === null) return null; + if (target.email === employee.email) { + io.print('자기 자신에게는 넘기지 않습니다.'); + return null; + } + if (target.locked) { + io.print('그 동료도 잠긴 계정입니다 — 다른 주소를 고르세요.'); + return null; + } + forwardTo = target.email; + } + let autoReply: LockRules['autoReply'] = null; + if (await io.confirm('보낸 사람에게 자동 응답을 보낼까요?')) { + const subject = (await io.ask('자동 응답 제목: ')).trim(); + if ( + subject === '' || + subject.length > MAX_VACATION_SUBJECT_LENGTH || + CONTROL_CHARACTER.test(subject) || + subject.includes(SIEVE_VARIABLE) + ) { + io.print( + `제목은 한 줄 1~${MAX_VACATION_SUBJECT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`, + ); + return null; + } + io.print( + '자동 응답 내용 — 여러 줄을 넣을 수 있습니다. 빈 줄을 넣으면 끝납니다.', + ); + const lines: string[] = []; + for (;;) { + const line = await io.ask('> '); + if (line === '') break; + lines.push(line); + } + const body = lines.join('\n'); + if ( + body === '' || + body.length > MAX_VACATION_TEXT_LENGTH || + lines.some((line) => CONTROL_CHARACTER.test(line)) || + body.includes(SIEVE_VARIABLE) + ) { + io.print( + `내용은 1~${MAX_VACATION_TEXT_LENGTH}자로 넣어 주세요 ('\${' 는 쓸 수 없습니다).`, + ); + return null; + } + autoReply = { subject, body }; + } + return { forwardTo, autoReply }; + } + + async unlock(io: OpsIo, operator: string): Promise { + const employee = await askEmployee(io, this.engine, this.domain); + if (employee === null) return; + if (!employee.locked) { + io.print('잠긴 계정이 아닙니다.'); + return; + } + if (employee.customPermissions) { + io.print( + '이 계정은 권한이 직접 정해져 있어 이 도구로 풀지 않습니다 — 엔진 관리 화면에서 하세요.', + ); + return; + } + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + '1. 로그인 권한 되돌리기 — 지금 비밀번호 · 앱 비밀번호로 다시 들어올 수 있다', + ' 잠글 때 건 넘기기 · 자동 응답은 푼 뒤에 뗄지 묻는다', + ' 털린 계정이면 비밀번호 재설정을 함께 하기를 권한다(다음에 묻는다)', + ]; + if (!(await confirmPlan(io, '잠금 풀기', lines))) return; + + try { + await this.engine.setLoginEnabled(employee.id, true); + step(io, true, '로그인 권한을 되돌렸습니다.'); + } catch (error) { + step(io, false, `풀지 못했습니다. ${failureLine(error)}`); + await this.audit.record({ + operator, + command: 'unlock', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + const notes: string[] = []; + let result: 'success' | 'partial' = 'success'; + try { + const rules = await this.engine.getLockRules(employee.email); + if (rules.forwardTo !== null || rules.autoReply) { + const what = [ + rules.forwardTo !== null ? `넘기기(→ ${rules.forwardTo})` : null, + rules.autoReply ? '자동 응답' : null, + ] + .filter(Boolean) + .join(' · '); + if (await io.confirm(`걸려 있는 것을 뗄까요? — ${what}`)) { + await this.engine.clearLockRules(employee.email); + step(io, true, `뗐습니다 — ${what}`); + notes.push(`${what} 뗌`); + } + } + } catch (error) { + result = 'partial'; + step( + io, + false, + `넘기기 · 자동 응답을 확인하지 못했습니다 — 직원이 설정 › 필터 · 부재중에서 끌 수 있습니다. ${failureLine(error)}`, + ); + notes.push(`Sieve 확인 실패 · ${failureDetail(error)}`); + } + await this.audit.record({ + operator, + command: 'unlock', + target: employee.email, + result, + detail: notes.join(' · ') || null, + }); + + if ( + await io.confirm('비밀번호 재설정을 함께 할까요? (털린 계정이면 권함)') + ) { + const refreshed = await this.engine + .findEmployee(employee.email) + .catch((error) => { + logUnknownFailure(error); + return null; + }); + if (refreshed === null) { + io.print( + '계정을 다시 읽지 못했습니다 — 「비밀번호 재설정」을 따로 하세요.', + ); + return; + } + if ( + await confirmPlan( + io, + '비밀번호 재설정', + this.accounts.resetPlan(refreshed), + ) + ) { + await this.accounts.runPasswordReset(io, operator, refreshed); + } + } + } + + async offboard(io: OpsIo, operator: string): Promise { + const email = await askAddress(io, this.domain, '퇴사한 직원 주소'); + if (email === null) return; + if (!this.engine.isEmployeeAddress(email)) { + io.print(NOT_EMPLOYEE_ADDRESS); + return; + } + let employee: EngineEmployee | null; + let footprint: AddressFootprint; + try { + employee = await this.engine.findEmployee(email); + // 계정은 있는데 직원(User)이 아니다 — 공유 메일함(Group) · 공용 주소. 이 도구로 지우지 않는다 + if (employee === null && (await this.engine.addressInUse(email))) { + io.print(NOT_EMPLOYEE_ACCOUNT); + return; + } + footprint = await this.footprintOf(email); + } catch (error) { + io.print(failureLine(error)); + return; + } + footprint = await this.chooseLastMemberLists(io, footprint); + + if (employee === null) { + // 엔진 계정은 이미 없다(지난 마무리가 DB 정리 전에 멈췄거나 엔진 관리 화면에서 지웠다) — 남은 것만 지운다 + if (footprintEmpty(footprint)) { + io.print('엔진에 그 계정이 없고 우리 DB 에 남은 기록도 없습니다.'); + return; + } + io.print('엔진에 그 계정이 없습니다 — 남은 기록만 지웁니다.'); + await this.purgeOnly(io, operator, email, footprint); + return; + } + if (!employee.locked) { + io.print('잠긴 계정만 마무리할 수 있습니다 — 먼저 「잠금」을 하세요.'); + return; + } + const lines = [ + `대상 ${employee.name} <${employee.email}>`, + `1. 엔진 계정 지우기 — 메일 ${bytesText(employee.usedBytes)} 이 영구 삭제된다(되돌릴 수 없다)`, + ...footprintLines(footprint, 2), + ]; + if (!(await confirmPlan(io, '퇴사 마무리', lines))) return; + if (!(await this.retyped(io, employee.email))) return; + + try { + await this.engine.deleteAccount(employee.id); + step(io, true, '엔진 계정을 지웠습니다.'); + } catch (error) { + step( + io, + false, + isUnknownOutcome(error) + ? `엔진 계정을 지웠는지 모릅니다 — ${failureLine(error)} 이미 지워졌으면 다시 할 때 남은 기록만 지웁니다.` + : `엔진 계정을 지우지 못했습니다 — 아무것도 바꾸지 않았습니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'offboard', + target: employee.email, + result: 'failure', + detail: failureDetail(error), + }); + return; + } + await this.purge(io, operator, employee.email, footprint, '엔진 계정 지움'); + } + + /** 그 주소가 남은 곳 — DB 줄 · 2단계 필수 예외 · 공용 주소 구성원 */ + private async footprintOf(email: string): Promise { + const [rows, exempt, lists] = await Promise.all([ + this.data.countRowsOf(email), + this.data.isTwoFactorExempt(email), + this.engine.listMailingLists(), + ]); + return { + rows, + exempt, + lists: lists.filter((list) => list.members.includes(email)), + keptLists: [], + }; + } + + /** + * 그 직원이 마지막 구성원인 공용 주소 — 빼면 받는 사람이 없다(「공용 주소」 명령은 마지막 구성원을 빼지 않는다). 그래도 뺄지 묻는다 (재리뷰 N4) + */ + private async chooseLastMemberLists( + io: OpsIo, + footprint: AddressFootprint, + ): Promise { + const last = footprint.lists.filter(isLastMember); + if (last.length === 0) return footprint; + io.print( + `그 직원이 마지막 구성원인 공용 주소가 있습니다 — 빼면 받는 사람이 없습니다: ${last.map((list) => list.email).join(', ')}`, + ); + if ( + await io.confirm( + '그 공용 주소에서도 뺄까요? (n 이면 남깁니다 — 다른 구성원을 넣은 뒤 「공용 주소」에서 빼세요)', + ) + ) { + return footprint; + } + return { + ...footprint, + lists: footprint.lists.filter((list) => !isLastMember(list)), + keptLists: last, + }; + } + + /** 엔진 계정이 없는 주소의 남은 것만 지운다 */ + private async purgeOnly( + io: OpsIo, + operator: string, + email: string, + footprint: AddressFootprint, + ): Promise { + const lines = [`주소 ${email}`, ...footprintLines(footprint, 1)]; + if (!(await confirmPlan(io, '퇴사 마무리(기록만)', lines))) return; + if (!(await this.retyped(io, email))) return; + await this.purge(io, operator, email, footprint, '엔진 계정 없음'); + } + + /** 우리 DB(한 트랜잭션 — 예외 주소 포함) → 공용 주소 구성원 → 실행 기록 */ + private async purge( + io: OpsIo, + operator: string, + email: string, + footprint: AddressFootprint, + first: string, + ): Promise { + const notes = [first]; + let result: 'success' | 'partial' = 'success'; + let purged: AddressPurge; + try { + purged = await this.data.purgeAddress(email); + } catch (error) { + step( + io, + false, + `우리 DB 의 기록을 지우지 못했습니다 — 「퇴사 마무리」를 다시 하면 남은 것만 지웁니다. ${failureLine(error)}`, + ); + await this.audit.record({ + operator, + command: 'offboard', + target: email, + result: 'partial', + detail: [...notes, `기록 지우기 실패 · ${failureDetail(error)}`].join( + ' · ', + ), + }); + return; + } + step(io, true, `우리 DB 의 기록 ${totalRows(purged.rows)}줄을 지웠습니다.`); + notes.push(`기록 ${totalRows(purged.rows)}줄 지움`); + if (purged.exemptRemoved) { + step(io, true, '2단계 인증 필수의 예외 주소에서 뺐습니다.'); + notes.push('예외 주소에서 뺌'); + } + if (purged.sessionsRevoked > 0) { + notes.push(`세션 ${purged.sessionsRevoked}개 끊음`); + } + if (purged.filesLeftForCleanup > 0) { + io.print( + ` 디스크의 파일 ${purged.filesLeftForCleanup}개는 정리 작업이 지웁니다.`, + ); + } + for (const list of footprint.lists) { + try { + await this.engine.setMailingListMember(list.id, email, false); + step(io, true, `공용 주소 ${list.email} 의 구성원에서 뺐습니다.`); + notes.push(`${list.email} 구성원에서 뺌`); + } catch (error) { + result = 'partial'; + step( + io, + false, + `공용 주소 ${list.email} 의 구성원에서 빼지 못했습니다 — 「공용 주소」에서 빼 주세요. ${failureLine(error)}`, + ); + notes.push(`${list.email} 빼기 실패 · ${failureDetail(error)}`); + } + } + await this.audit.record({ + operator, + command: 'offboard', + target: email, + result, + detail: notes.join(' · '), + }); + } + + /** 주소를 다시 쳐서 확인 — 정확히 같아야 한다 (소문자로 견준다) */ + private async retyped(io: OpsIo, email: string): Promise { + const typed = ( + await io.ask('지우려면 주소 전체를 다시 쳐 주세요: ') + ).toLowerCase(); + if (typed !== email) { + io.print('주소가 같지 않아 하지 않았습니다.'); + return false; + } + return true; + } +} diff --git a/backend/src/modules/ops/ops-audit-log.entity.ts b/backend/src/modules/ops/ops-audit-log.entity.ts new file mode 100644 index 0000000..83de0c5 --- /dev/null +++ b/backend/src/modules/ops/ops-audit-log.entity.ts @@ -0,0 +1,47 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** 운영자 이름 길이 */ +export const OPS_OPERATOR_MAX_LENGTH = 100; +/** 명령 이름 길이 */ +const COMMAND_COLUMN_LENGTH = 32; +/** 대상 — 주소 · 공용 주소 · 설정 이름 (메일 주소 최대 길이) */ +export const OPS_TARGET_MAX_LENGTH = 254; +/** 결과 — success · failure · partial */ +const RESULT_COLUMN_LENGTH = 16; +/** 덧붙이는 말 — 비밀은 넣지 않는다 */ +export const OPS_DETAIL_MAX_LENGTH = 500; + +/** 실행 결과 */ +export type OpsAuditResult = 'success' | 'failure' | 'partial'; + +/** + * 운영 도구의 실행 기록 (13단계 — 계약 6절 "운영 도구") — 언제 · 누가 · 명령 · 대상 · 결과. + * 비밀(관리자 비밀번호 · 임시 비밀번호 · 코드)은 남기지 않는다. 퇴사 마무리도 이 표는 지우지 않는다(DB 백업에 든다) + */ +@Entity({ name: 'ops_audit_log' }) +export class OpsAuditLogEntity { + /** 차례 번호 (bigint — 문자열로 읽힌다) */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id!: string; + + @Index('IDX_ops_audit_log_occurred_at') + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + /** 운영자 이름 — 도구를 시작할 때 넣은 이름 */ + @Column({ type: 'varchar', length: OPS_OPERATOR_MAX_LENGTH }) + operator!: string; + + /** 명령 — hire · password-reset · two-factor-reset · lock · unlock · offboard · link-revoke · mailing-list · policy · support-contact */ + @Column({ type: 'varchar', length: COMMAND_COLUMN_LENGTH }) + command!: string; + + @Column({ type: 'varchar', length: OPS_TARGET_MAX_LENGTH, nullable: true }) + target!: string | null; + + @Column({ type: 'varchar', length: RESULT_COLUMN_LENGTH }) + result!: OpsAuditResult; + + @Column({ type: 'varchar', length: OPS_DETAIL_MAX_LENGTH, nullable: true }) + detail!: string | null; +} diff --git a/backend/src/modules/ops/ops-audit.service.ts b/backend/src/modules/ops/ops-audit.service.ts new file mode 100644 index 0000000..cedaac3 --- /dev/null +++ b/backend/src/modules/ops/ops-audit.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { + OPS_DETAIL_MAX_LENGTH, + OPS_OPERATOR_MAX_LENGTH, + OPS_TARGET_MAX_LENGTH, + OpsAuditLogEntity, + type OpsAuditResult, +} from './ops-audit-log.entity'; + +/** 실행 기록 한 줄 — 비밀(비밀번호 · 코드)은 넣지 않는다 */ +export interface OpsAuditEntry { + readonly operator: string; + readonly command: string; + readonly target: string | null; + readonly result: OpsAuditResult; + readonly detail?: string | null; +} + +function clip(text: string | null | undefined, max: number): string | null { + if (text === null || text === undefined || text === '') return null; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +/** + * 운영 도구의 실행 기록 (계약 6절 — ops_audit_log). 기록하지 못해도 명령의 결과는 그대로다 — 남기지 못했다는 것을 알린다(false) + */ +@Injectable() +export class OpsAuditService { + private readonly logger = new Logger(OpsAuditService.name); + + constructor( + @InjectRepository(OpsAuditLogEntity) + private readonly rows: Repository, + ) {} + + async record(entry: OpsAuditEntry, at: Date = new Date()): Promise { + try { + await this.rows.insert({ + occurredAt: at, + operator: + clip(entry.operator, OPS_OPERATOR_MAX_LENGTH) ?? '(이름 없음)', + command: entry.command, + target: clip(entry.target, OPS_TARGET_MAX_LENGTH), + result: entry.result, + detail: clip(entry.detail, OPS_DETAIL_MAX_LENGTH), + }); + return true; + } catch (error) { + this.logger.error( + `[SYS_001] 운영 도구의 실행 기록을 남기지 못했습니다: command=${entry.command}, reason=${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } +} diff --git a/backend/src/modules/ops/ops-cli.ts b/backend/src/modules/ops/ops-cli.ts new file mode 100644 index 0000000..172aa79 --- /dev/null +++ b/backend/src/modules/ops/ops-cli.ts @@ -0,0 +1,189 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import { OPS_OPERATOR_MAX_LENGTH } from './ops-audit-log.entity'; +import { OPS_ENGINE, OpsEngineError, type OpsEngine } from './ops-engine.port'; +import { OpsInputClosed, type OpsIo } from './ops-io'; +import { RULE, failureLine } from './ops-prompts'; + +/** 운영자 이름에 받지 않는 제어 문자 */ +// eslint-disable-next-line no-control-regex +const OPERATOR_CONTROL = /[\u0000-\u001f]/; + +/** 관리자 비밀번호를 다시 묻는 횟수 */ +const SIGN_IN_ATTEMPTS = 3; + +/** 메뉴 — 번호 · 이름 (계약 6절 "운영 도구"의 명령 열 개와 차례 그대로) */ +export const OPS_MENU = [ + ['1', '입사'], + ['2', '비밀번호 재설정'], + ['3', '2단계 해제'], + ['4', '잠금'], + ['5', '잠금 풀기'], + ['6', '퇴사 마무리'], + ['7', '링크 끊기'], + ['8', '공용 주소'], + ['9', '현황'], + ['10', '정책 · 설정'], + ['0', '끝내기'], +] as const; + +/** + * 운영 도구의 차례 (13단계 — 계약 6절 "운영 도구") — 운영자 이름 · 관리자 비밀번호(화면에 보이지 않게 · 저장하지 않음)를 묻고 메뉴를 돈다. + * 명령마다 묻고 → 바뀔 것을 보이고 → y/N → 실행 → 실행 기록. 입력이 끝나면(Ctrl+D · Ctrl+C) 조용히 끝낸다 + */ +@Injectable() +export class OpsCli { + constructor( + @Inject(OPS_ENGINE) private readonly engine: OpsEngine, + private readonly accounts: AccountCommands, + private readonly locks: LockCommands, + private readonly admin: AdminCommands, + ) {} + + /** 끝나면 종료 코드 — 0 정상, 1 들어가지 못함 */ + async run(io: OpsIo): Promise { + try { + io.print(RULE); + io.print(' ARA SYSTEM 운영 도구'); + io.print( + ' 바꾸기 전에 늘 바뀔 것을 보여 주고 y/N 으로 묻습니다. 한 일은 실행 기록에 남습니다.', + ); + io.print(RULE); + if (!io.interactive) { + // 비밀번호를 묻기 전에 — 파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다 + io.print( + '이 도구는 터미널에서 사람이 돌립니다 — 관리자 비밀번호를 화면에 보이지 않게 묻기 위해서입니다.', + ); + io.print( + ' sudo docker exec -it -backend node dist/cli/ops.js', + ); + io.print( + '(-it 를 빠뜨리면 표준 입력이 터미널이 아니라 이 안내가 나옵니다)', + ); + return 1; + } + if (io.pipedForTesting) { + io.print( + '주의: 터미널이 아닌 입력으로 돌고 있습니다(OPS_ALLOW_NO_TTY=1 — 자동 시험용). 사람은 docker exec -it 로 실행하세요.', + ); + } + const operator = await this.askOperator(io); + if (operator === null) return 1; + if (!(await this.signIn(io))) return 1; + await this.menu(io, operator); + return 0; + } catch (error) { + if (error instanceof OpsInputClosed) { + io.print(''); + io.print('끝냅니다.'); + return 0; + } + throw error; + } + } + + private async askOperator(io: OpsIo): Promise { + for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) { + const name = ( + await io.ask('운영자 이름 (실행 기록에 남습니다): ') + ).trim(); + if ( + name !== '' && + name.length <= OPS_OPERATOR_MAX_LENGTH && + !OPERATOR_CONTROL.test(name) + ) { + return name; + } + io.print(`이름은 한 줄 1~${OPS_OPERATOR_MAX_LENGTH}자로 넣어 주세요.`); + } + return null; + } + + private async signIn(io: OpsIo): Promise { + for (let attempt = 0; attempt < SIGN_IN_ATTEMPTS; attempt += 1) { + const password = await io.askSecret( + '메일 엔진 관리자 비밀번호 (화면에 보이지 않습니다 · 저장하지 않습니다): ', + ); + if (password === '') continue; + try { + const result = await this.engine.signIn(password); + if (result === 'ok') return true; + if (result === 'two-factor') { + io.print( + '관리자 계정에 2단계 인증이 켜져 있어 이 도구로 들어갈 수 없습니다.', + ); + return false; + } + if (result === 'not-admin') { + io.print('그 계정에는 계정 관리 권한이 없습니다.'); + return false; + } + io.print('관리자 비밀번호가 맞지 않습니다.'); + } catch (error) { + io.print(failureLine(error)); + return false; + } + } + io.print('들어가지 못했습니다.'); + return false; + } + + private async menu(io: OpsIo, operator: string): Promise { + for (;;) { + io.print(''); + io.print( + OPS_MENU.map(([key, label]) => `${key.padStart(2)}. ${label}`).join( + '\n', + ), + ); + const choice = (await io.ask('번호를 고르세요: ')).trim(); + if (choice === '0') { + io.print('끝냅니다.'); + return; + } + try { + await this.dispatch(io, operator, choice); + } catch (error) { + if (error instanceof OpsInputClosed) throw error; + io.print(failureLine(error)); + if (error instanceof OpsEngineError && error.kind === 'not-signed-in') { + return; + } + } + } + } + + private async dispatch( + io: OpsIo, + operator: string, + choice: string, + ): Promise { + switch (choice) { + case '1': + return this.accounts.hire(io, operator); + case '2': + return this.accounts.resetPassword(io, operator); + case '3': + return this.accounts.resetTwoFactor(io, operator); + case '4': + return this.locks.lock(io, operator); + case '5': + return this.locks.unlock(io, operator); + case '6': + return this.locks.offboard(io, operator); + case '7': + return this.admin.revokeLink(io, operator); + case '8': + return this.admin.mailingLists(io, operator); + case '9': + return this.admin.status(io); + case '10': + return this.admin.policy(io, operator); + default: + io.print('메뉴의 번호를 넣어 주세요.'); + } + } +} diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts new file mode 100644 index 0000000..31879dc --- /dev/null +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -0,0 +1,1243 @@ +import { Logger } from '@nestjs/common'; + +import type { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import type { MailSettings } from '@/modules/mail/mail.settings'; +import type { SystemMailService } from '@/modules/system-mail/system-mail.service'; +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { + DEFAULT_SYSTEM_SETTINGS, + type SystemSettings, +} from '@/modules/system-settings/system-settings.types'; +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import type { OpsAuditEntry, OpsAuditService } from './ops-audit.service'; +import { OpsCli } from './ops-cli'; +import type { AddressRows, OpsDataService } from './ops-data.service'; +import { + OpsEngineError, + type EngineEmployee, + type EngineMailingList, + type LockRules, + type OpsEngine, + type OpsSignInResult, +} from './ops-engine.port'; +import { OpsInputClosed, type OpsIo } from './ops-io'; + +const DOMAIN = 'arasystem.test'; +const ARA = `ara.kim@${DOMAIN}`; +const DOYUN = `doyun.lee@${DOMAIN}`; +const OPERATOR = '운영자 김'; + +const mailSettings = { domain: DOMAIN } as unknown as MailSettings; + +const NO_ROWS: AddressRows = { + loginEvents: 0, + appPasswordUsages: 0, + twoFactorSettings: 0, + signatures: 0, + signatureImages: 0, + mailSettings: 0, + directory: 0, + files: 0, + shareLinks: 0, +}; + +function employee(overrides: Partial = {}): EngineEmployee { + return { + id: 'acc-ara', + email: ARA, + name: '김아라', + locked: false, + customPermissions: false, + passwordExpired: false, + twoFactorEnabled: false, + appPasswords: 2, + usedBytes: 1024, + quotaBytes: null, + ...overrides, + }; +} + +/** 줄 입력 흉내 — 답을 차례로 주고, 보인 줄을 모은다. 답이 떨어지면 입력이 끝난 것으로 */ +class ScriptedIo implements OpsIo { + readonly printed: string[] = []; + readonly asked: string[] = []; + + constructor( + private readonly answers: string[], + readonly interactive = true, + ) {} + + print(line = ''): void { + this.printed.push(line); + } + + ask(question: string): Promise { + this.asked.push(question); + return this.next().then((answer) => answer.trim()); + } + + askSecret(question: string): Promise { + this.asked.push(question); + return this.next(); + } + + async confirm(question: string): Promise { + this.asked.push(question); + return ['y', 'yes', '예'].includes((await this.next()).toLowerCase()); + } + + close(): void {} + + get output(): string { + return this.printed.join('\n'); + } + + private next(): Promise { + const answer = this.answers.shift(); + return answer === undefined + ? Promise.reject(new OpsInputClosed()) + : Promise.resolve(answer); + } +} + +/** 직원이 아닌 주소 (관리자 · 게이트웨이 · 사내 목록 · 막은 계정) */ +const ADMIN = `admin@${DOMAIN}`; + +/** 엔진 흉내 — 부른 차례를 남긴다 */ +class FakeEngine implements OpsEngine { + readonly calls: string[] = []; + /** 직원(User)이 아닌 계정이 쓰는 주소 — Group 계정 등 */ + readonly otherAccounts = new Set(); + readonly employees = new Map(); + readonly lists: EngineMailingList[] = []; + readonly createdPasswords: string[] = []; + readonly resetPasswords: string[] = []; + signInResults: OpsSignInResult[] = ['ok']; + lockRules: { forwardTo: string | null; autoReply: boolean } = { + forwardTo: null, + autoReply: false, + }; + /** 메서드 이름 → 던질 실패 */ + readonly failures = new Map(); + + private fail(name: string): void { + const failure = this.failures.get(name); + if (failure) { + this.failures.delete(name); + throw failure; + } + } + + signIn(): Promise { + this.calls.push('signIn'); + return Promise.resolve(this.signInResults.shift() ?? 'rejected'); + } + + isEmployeeAddress(email: string): boolean { + return email.endsWith(`@${DOMAIN}`) && email !== ADMIN; + } + + findEmployee(email: string): Promise { + this.calls.push(`find:${email}`); + return Promise.resolve(this.employees.get(email) ?? null); + } + + listEmployees(): Promise { + return Promise.resolve([...this.employees.values()]); + } + + addressInUse(email: string): Promise { + return Promise.resolve( + this.employees.has(email) || + this.otherAccounts.has(email) || + this.lists.some((l) => l.email === email), + ); + } + + createEmployee(input: { + localPart: string; + name: string; + quotaBytes: number | null; + tempPassword: string; + }): Promise { + this.calls.push(`create:${input.localPart}`); + this.createdPasswords.push(input.tempPassword); + this.fail('createEmployee'); + const created = employee({ + id: `acc-${input.localPart}`, + email: `${input.localPart}@${DOMAIN}`, + name: input.name, + quotaBytes: input.quotaBytes, + passwordExpired: true, + appPasswords: 0, + }); + this.employees.set(created.email, created); + return Promise.resolve(created); + } + + resetPassword(id: string, tempPassword: string): Promise { + this.calls.push(`resetPassword:${id}`); + this.resetPasswords.push(tempPassword); + this.fail('resetPassword'); + return Promise.resolve(); + } + + removeTwoFactor(id: string): Promise { + this.calls.push(`removeTwoFactor:${id}`); + this.fail('removeTwoFactor'); + return Promise.resolve(); + } + + setLoginEnabled(id: string, enabled: boolean): Promise { + this.calls.push(`setLogin:${id}:${enabled}`); + this.fail('setLoginEnabled'); + return Promise.resolve(); + } + + deleteAccount(id: string): Promise { + this.calls.push(`delete:${id}`); + this.fail('deleteAccount'); + return Promise.resolve(); + } + + listMailingLists(): Promise { + return Promise.resolve(this.lists); + } + + createMailingList(input: { + localPart: string; + name: string; + members: readonly string[]; + }): Promise { + this.calls.push(`createList:${input.localPart}:${input.members.join(',')}`); + const list = { + id: 'l1', + email: `${input.localPart}@${DOMAIN}`, + name: input.name, + members: [...input.members], + }; + this.lists.push(list); + return Promise.resolve(list); + } + + setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise { + this.calls.push(`member:${id}:${member}:${included}`); + this.fail('setMailingListMember'); + return Promise.resolve(); + } + + deleteAppPasswords(email: string): Promise { + this.calls.push(`deleteAppPasswords:${email}`); + this.fail('deleteAppPasswords'); + return Promise.resolve(2); + } + + getLockRules(): Promise<{ forwardTo: string | null; autoReply: boolean }> { + this.calls.push('getLockRules'); + return Promise.resolve(this.lockRules); + } + + applyLockRules(email: string, rules: LockRules): Promise { + this.calls.push( + `applyLockRules:${email}:${rules.forwardTo ?? '-'}:${rules.autoReply?.subject ?? '-'}`, + ); + this.fail('applyLockRules'); + return Promise.resolve(); + } + + clearLockRules(email: string): Promise { + this.calls.push(`clearLockRules:${email}`); + this.fail('clearLockRules'); + return Promise.resolve(); + } +} + +describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () => { + let engine: FakeEngine; + let audit: { entries: OpsAuditEntry[]; record: jest.Mock }; + let data: { + revokeSessions: jest.Mock; + forgetTwoFactor: jest.Mock; + countRowsOf: jest.Mock; + isTwoFactorExempt: jest.Mock; + changeTwoFactorExempt: jest.Mock; + purgeAddress: jest.Mock; + lastWebmailLogins: jest.Mock; + }; + let systemMail: { + notifyPasswordReset: jest.Mock; + notifyTwoFactorReset: jest.Mock; + remove: jest.Mock; + }; + let sharing: { + revokeAllOfOwnerAsOperator: jest.Mock; + findForOperator: jest.Mock; + revokeAsOperator: jest.Mock; + }; + let settingsValue: SystemSettings; + let settings: { reload: jest.Mock; update: jest.Mock }; + let accounts: AccountCommands; + let locks: LockCommands; + let admin: AdminCommands; + + /** 엔진 · 서비스가 불린 차례 (한 줄로) */ + const order = (): string[] => [...engine.calls]; + + let errorLogs: string[]; + + beforeEach(() => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + errorLogs = []; + jest + .spyOn(Logger.prototype, 'error') + .mockImplementation( + (message: unknown) => void errorLogs.push(String(message)), + ); + engine = new FakeEngine(); + engine.employees.set(ARA, employee()); + engine.employees.set( + DOYUN, + employee({ id: 'acc-doyun', email: DOYUN, name: '이도윤' }), + ); + const entries: OpsAuditEntry[] = []; + audit = { + entries, + record: jest.fn((entry: OpsAuditEntry) => { + entries.push(entry); + return Promise.resolve(true); + }), + }; + data = { + revokeSessions: jest.fn((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(3); + }), + forgetTwoFactor: jest.fn((email: string) => { + engine.calls.push(`forgetTwoFactor:${email}`); + return Promise.resolve(); + }), + countRowsOf: jest.fn().mockResolvedValue(NO_ROWS), + isTwoFactorExempt: jest.fn().mockResolvedValue(false), + // 실제처럼 그때의 목록에 넣고 뺀다 — 바꿨으면 true + changeTwoFactorExempt: jest.fn((email: string, include: boolean) => { + const exempt = settingsValue.twoFactor.exempt; + if (include === exempt.includes(email)) return Promise.resolve(false); + settingsValue = { + ...settingsValue, + twoFactor: { + ...settingsValue.twoFactor, + exempt: include + ? [...exempt, email] + : exempt.filter((entry) => entry !== email), + }, + }; + return Promise.resolve(true); + }), + purgeAddress: jest.fn((email: string) => { + engine.calls.push(`purge:${email}`); + return Promise.resolve({ + rows: { ...NO_ROWS, loginEvents: 4 }, + exemptRemoved: false, + sessionsRevoked: 0, + filesLeftForCleanup: 0, + }); + }), + lastWebmailLogins: jest.fn().mockResolvedValue(new Map()), + }; + systemMail = { + notifyPasswordReset: jest.fn((email: string) => { + engine.calls.push(`notifyPasswordReset:${email}`); + return Promise.resolve('notice-1'); + }), + notifyTwoFactorReset: jest.fn((email: string) => { + engine.calls.push(`notifyTwoFactorReset:${email}`); + return Promise.resolve('notice-2'); + }), + remove: jest.fn((email: string, id: string) => { + engine.calls.push(`removeNotice:${email}:${id}`); + return Promise.resolve(true); + }), + }; + sharing = { + revokeAllOfOwnerAsOperator: jest.fn((email: string) => { + engine.calls.push(`revokeLinks:${email}`); + return Promise.resolve(2); + }), + findForOperator: jest.fn().mockResolvedValue(null), + revokeAsOperator: jest.fn().mockResolvedValue(true), + }; + settingsValue = DEFAULT_SYSTEM_SETTINGS; + settings = { + reload: jest.fn(() => Promise.resolve(settingsValue)), + update: jest.fn( + (patch: { + twoFactor?: Partial; + supportContact?: string | null; + }) => { + settingsValue = { + twoFactor: { ...settingsValue.twoFactor, ...patch.twoFactor }, + supportContact: + patch.supportContact === undefined + ? settingsValue.supportContact + : patch.supportContact, + }; + return Promise.resolve(settingsValue); + }, + ), + }; + accounts = new AccountCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + systemMail as unknown as SystemMailService, + settings as unknown as SystemSettingsService, + ); + locks = new LockCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + sharing as unknown as FileSharingService, + accounts, + ); + admin = new AdminCommands( + engine, + mailSettings, + data as unknown as OpsDataService, + audit as unknown as OpsAuditService, + sharing as unknown as FileSharingService, + settings as unknown as SystemSettingsService, + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('입사', () => { + it('계정을 만들고 임시 비밀번호를 한 번만 보인다 — 실행 기록에는 비밀이 없다', async () => { + const io = new ScriptedIo(['hong.gildong', '홍길동', '10', 'y']); + await accounts.hire(io, OPERATOR); + + expect(order()).toContain('create:hong.gildong'); + const [password] = engine.createdPasswords; + expect(password).toMatch(/^[A-Za-z0-9]{16}$/); + expect(io.printed.filter((line) => line.includes(password))).toHaveLength( + 1, + ); + expect(audit.entries).toEqual([ + { + operator: OPERATOR, + command: 'hire', + target: `hong.gildong@${DOMAIN}`, + result: 'success', + detail: '용량 10 GB', + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain(password); + }); + + it('n 이면 아무것도 만들지 않는다', async () => { + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'n']), + OPERATOR, + ); + expect(order().some((call) => call.startsWith('create:'))).toBe(false); + expect(audit.entries).toEqual([]); + }); + + it('이미 쓰는 주소 · 다른 도메인 · 잘못된 용량은 묻는 데서 멈춘다', async () => { + const used = new ScriptedIo(['ara.kim']); + await accounts.hire(used, OPERATOR); + expect(used.output).toContain('이미 쓰고 있습니다'); + + const other = new ScriptedIo(['hong@example.com']); + await accounts.hire(other, OPERATOR); + expect(other.output).toContain('주소 모양이 맞지 않거나'); + + const quota = new ScriptedIo(['hong.gildong', '홍길동', '-1']); + await accounts.hire(quota, OPERATOR); + expect(quota.output).toContain('용량은'); + expect(order().some((call) => call.startsWith('create:'))).toBe(false); + }); + + it('같은 주소의 옛 기록이 남았으면 묻고 지운 뒤 만든다', async () => { + data.countRowsOf.mockResolvedValueOnce({ ...NO_ROWS, loginEvents: 5 }); + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'y', 'y']), + OPERATOR, + ); + expect(order()).toEqual([ + `purge:hong.gildong@${DOMAIN}`, + 'create:hong.gildong', + ]); + }); + + it('엔진이 임시 비밀번호를 약하다고 받지 않으면 새로 만들어 다시 한다', async () => { + engine.failures.set( + 'createEmployee', + new OpsEngineError('weak-password'), + ); + await accounts.hire( + new ScriptedIo(['hong.gildong', '홍길동', '', 'y']), + OPERATOR, + ); + expect(engine.createdPasswords).toHaveLength(2); + expect(engine.createdPasswords[0]).not.toBe(engine.createdPasswords[1]); + expect(audit.entries[0].result).toBe('success'); + }); + }); + + describe('비밀번호 재설정', () => { + it('차례 — 앱 비밀번호 지우기 → 세션 끊기 → 알림 메일(말하는 일이 끝난 뒤) → 엔진 임시 비밀번호 + 만료 → 그사이 새 세션 끊기', async () => { + data.revokeSessions + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(3); + }) + .mockImplementationOnce((email: string) => { + engine.calls.push(`revokeSessions:${email}`); + return Promise.resolve(1); + }); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + `revokeSessions:${ARA}`, + ]); + const [password] = engine.resetPasswords; + expect(io.printed.filter((line) => line.includes(password))).toHaveLength( + 1, + ); + expect(io.output).toContain( + '그사이 새로 로그인한 웹메일 1개도 끊었습니다', + ); + expect(audit.entries[0]).toMatchObject({ + command: 'password-reset', + target: ARA, + result: 'success', + detail: '앱 비밀번호 2개 지움 · 알림 메일 넣음 · 세션 4개 끊음', + }); + }); + + it('엔진 바꾸기가 실패하면 넣은 알림 메일을 지운다(아직 만료 전이라 지울 수 있다)', async () => { + engine.failures.set('resetPassword', new OpsEngineError('unavailable')); + await accounts.resetPassword(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + `removeNotice:${ARA}:notice-1`, + ]); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('비밀번호를 바꿨는지 모르면(엔진 응답 없음) 알림 메일을 지우지 않고 "결과를 모릅니다 — 다시"로 알린다 (재리뷰 N3)', async () => { + engine.failures.set( + 'resetPassword', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `deleteAppPasswords:${ARA}`, + `revokeSessions:${ARA}`, + `notifyPasswordReset:${ARA}`, + 'resetPassword:acc-ara', + ]); + expect(io.output).toContain('비밀번호를 바꿨는지 모릅니다'); + expect(io.output).toContain('알림 메일은 그대로 둡니다'); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + expect(audit.entries[0].detail).toContain('결과 모름'); + }); + + it('세션을 끊지 못하면 알림을 넣지 않고 멈춘다 — 알림이 사실과 어긋나지 않게', async () => { + data.revokeSessions.mockRejectedValueOnce(new Error('connection lost')); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `deleteAppPasswords:${ARA}`]); + expect(io.output).toContain('비밀번호는 그대로입니다'); + expect(audit.entries[0].result).toBe('failure'); + // 모르는 오류는 오류 로그에 남는다(L8) + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('앱 비밀번호를 지우지 못하면 아무것도 바꾸지 않고 멈춘다', async () => { + engine.failures.set( + 'deleteAppPasswords', + new OpsEngineError('unavailable'), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `deleteAppPasswords:${ARA}`]); + expect(io.output).toContain('아무것도 바꾸지 않았습니다'); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('알림 메일을 넣지 못해도 재설정은 이어 간다', async () => { + systemMail.notifyPasswordReset.mockResolvedValueOnce(null); + await accounts.resetPassword(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toContain('resetPassword:acc-ara'); + expect(audit.entries[0].detail).toContain('알림 메일 못 넣음'); + }); + + it('이미 만료된 계정은 앱 비밀번호 지우기 · 알림 넣기를 건너뛴다(엔진이 대리 접근을 막는다) — 바뀔 것에도 보인다', async () => { + engine.employees.set( + ARA, + employee({ passwordExpired: true, appPasswords: 0 }), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetPassword(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `revokeSessions:${ARA}`, + 'resetPassword:acc-ara', + `revokeSessions:${ARA}`, + ]); + expect(io.output).toContain('1. 앱 비밀번호 — 건너뜀'); + expect(io.output).toContain('3. 받은편지함 알림 메일 — 건너뜀'); + expect(audit.entries[0].detail).toContain('알림 메일 건너뜀(이미 만료)'); + }); + + it('잠긴 계정은 먼저 풀라고 안내하고 하지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + const io = new ScriptedIo(['ara.kim']); + await accounts.resetPassword(io, OPERATOR); + expect(io.output).toContain('잠금 풀기'); + expect(order()).toEqual([`find:${ARA}`]); + }); + }); + + describe('2단계 해제', () => { + it('엔진 OTP 지우기 → 켠 때 기록 정리 → 알림 메일', async () => { + engine.employees.set(ARA, employee({ twoFactorEnabled: true })); + await accounts.resetTwoFactor(new ScriptedIo(['ara.kim', 'y']), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'removeTwoFactor:acc-ara', + `forgetTwoFactor:${ARA}`, + `notifyTwoFactorReset:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'two-factor-reset', + result: 'success', + }); + }); + + it('꺼져 있으면 할 것이 없다 — 묻지 않고 DB 도 바꾸지 않는다(기록 없이 바꾸지 않게)', async () => { + const io = new ScriptedIo(['ara.kim']); + await accounts.resetTwoFactor(io, OPERATOR); + expect(io.output).toContain('꺼져 있습니다'); + expect(order()).toEqual([`find:${ARA}`]); + expect(data.forgetTwoFactor).not.toHaveBeenCalled(); + expect(audit.entries).toEqual([]); + }); + + it('비밀번호가 만료된 계정은 알림을 건너뛴다', async () => { + engine.employees.set( + ARA, + employee({ twoFactorEnabled: true, passwordExpired: true }), + ); + const io = new ScriptedIo(['ara.kim', 'y']); + await accounts.resetTwoFactor(io, OPERATOR); + expect(order()).not.toContain(`notifyTwoFactorReset:${ARA}`); + expect(io.output).toContain('3. 받은편지함 알림 메일 — 건너뜀'); + }); + }); + + describe('잠금 · 잠금 풀기', () => { + it('차례 — 넘기기 · 자동 응답(Sieve)을 먼저 → 로그인 권한 빼기 → 세션 끊기 → 링크 끊기', async () => { + const io = new ScriptedIo([ + 'ara.kim', + 'y', + 'doyun.lee', + 'y', + '부재 안내', + '퇴사했습니다.', + '', + 'y', + 'y', + ]); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:부재 안내`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + `revokeLinks:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'lock', + result: 'success', + }); + }); + + it('로그인 권한을 빼지 못하면 세션은 끊어 두고, 걸어 둔 넘기기 · 자동 응답을 뗄지 묻는다', async () => { + engine.failures.set( + 'setLoginEnabled', + new OpsEngineError('refused', 'invalidProperties'), + ); + const io = new ScriptedIo([ + 'ara.kim', + 'y', + 'doyun.lee', + 'n', + 'n', + 'y', + 'y', + ]); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:-`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + `clearLockRules:${ARA}`, + ]); + expect(io.output).toContain('다시 「잠금」을 하세요'); + expect(audit.entries[0]).toMatchObject({ result: 'failure' }); + expect(audit.entries[0].detail).toContain('세션 3개 끊음'); + expect(audit.entries[0].detail).toContain('Sieve 뗌'); + }); + + it('잠갔는지 모르면(엔진 응답 없음) "결과를 모릅니다 — 다시"로 알리고, 세션은 끊되 Sieve 떼기는 묻지 않는다 (재리뷰 N3)', async () => { + engine.failures.set( + 'setLoginEnabled', + new OpsEngineError('unavailable', 'timeout'), + ); + const io = new ScriptedIo(['ara.kim', 'y', 'doyun.lee', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + `find:${DOYUN}`, + `applyLockRules:${ARA}:${DOYUN}:-`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + expect(io.output).toContain('잠갔는지 모릅니다'); + expect(io.output).toContain('같은 명령을 다시 하세요'); + expect(io.asked.some((question) => question.includes('뗄까요'))).toBe( + false, + ); + expect(audit.entries[0].detail).toContain('잠금 결과 모름'); + }); + + it('로그인 권한을 빼지 못했고 Sieve 를 걸지 않았으면 떼기를 묻지 않는다', async () => { + engine.failures.set('setLoginEnabled', new OpsEngineError('refused')); + const io = new ScriptedIo(['ara.kim', 'n', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + expect(io.asked.some((question) => question.includes('뗄까요'))).toBe( + false, + ); + }); + + it('Sieve 를 걸지 못하면 잠그지 않는다', async () => { + engine.failures.set( + 'applyLockRules', + new OpsEngineError('too-many-rules'), + ); + const io = new ScriptedIo(['ara.kim', 'y', 'doyun.lee', 'n', 'n', 'y']); + await locks.lock(io, OPERATOR); + expect(order()).not.toContain('setLogin:acc-ara:false'); + expect(io.output).toContain('잠그지 않았습니다'); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('넘기기 · 자동 응답이 없으면 Sieve 는 건드리지 않는다 · 자동 응답에 ${ 는 받지 않는다', async () => { + await locks.lock( + new ScriptedIo(['ara.kim', 'n', 'n', 'n', 'y']), + OPERATOR, + ); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:false', + `revokeSessions:${ARA}`, + ]); + + engine.employees.set(ARA, employee()); + engine.calls.length = 0; + const io = new ScriptedIo(['ara.kim', 'n', 'y', '안내 ${x}']); + await locks.lock(io, OPERATOR); + expect(order()).toEqual([`find:${ARA}`]); + }); + + it('자기 자신 · 잠긴 동료에게는 넘기지 않는다', async () => { + const self = new ScriptedIo(['ara.kim', 'y', 'ara.kim']); + await locks.lock(self, OPERATOR); + expect(self.output).toContain('자기 자신'); + + engine.employees.set( + DOYUN, + employee({ id: 'acc-doyun', email: DOYUN, locked: true }), + ); + const locked = new ScriptedIo(['ara.kim', 'y', 'doyun.lee']); + await locks.lock(locked, OPERATOR); + expect(locked.output).toContain('잠긴 계정'); + expect(order().some((call) => call.startsWith('setLogin'))).toBe(false); + }); + + it('잠금 풀기 — 권한을 되돌린 뒤 넘기기 · 자동 응답을 묻고 떼고, 비밀번호 재설정을 권한다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lockRules = { forwardTo: DOYUN, autoReply: true }; + const io = new ScriptedIo(['ara.kim', 'y', 'y', 'n']); + await locks.unlock(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'setLogin:acc-ara:true', + 'getLockRules', + `clearLockRules:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'unlock', + result: 'success', + }); + }); + }); + + describe('직원이 아닌 주소 · 계정', () => { + it('관리자 · 게이트웨이 · 막은 계정 주소는 "직원 주소가 아님" — 엔진에 묻지 않고, 퇴사 마무리도 기록 정리를 권하지 않는다', async () => { + for (const run of [ + (io: ScriptedIo) => locks.lock(io, OPERATOR), + (io: ScriptedIo) => locks.offboard(io, OPERATOR), + (io: ScriptedIo) => accounts.resetPassword(io, OPERATOR), + (io: ScriptedIo) => accounts.hire(io, OPERATOR), + ]) { + const io = new ScriptedIo(['admin']); + await run(io); + expect(io.output).toMatch(/직원 주소(가 아닙니다|로 쓸 수 없습니다)/); + expect(io.output).not.toContain('기록만 지웁니다'); + } + expect(order()).toEqual([]); + expect(data.countRowsOf).not.toHaveBeenCalled(); + }); + + it('Group(공유 메일함) · 공용 주소처럼 직원이 아닌 계정은 "직원 계정이 아님" — 퇴사 마무리가 지우지 않는다', async () => { + engine.otherAccounts.add(`team@${DOMAIN}`); + const lockIo = new ScriptedIo(['team']); + await locks.lock(lockIo, OPERATOR); + expect(lockIo.output).toContain('직원 계정이 아닙니다'); + + const offboardIo = new ScriptedIo(['team']); + await locks.offboard(offboardIo, OPERATOR); + expect(offboardIo.output).toContain('직원 계정이 아닙니다'); + expect(order().some((call) => call.startsWith('purge:'))).toBe(false); + }); + + it('엔진에 없는 직원 주소는 "엔진에 그 직원 계정이 없습니다"', async () => { + const io = new ScriptedIo(['nobody']); + await locks.lock(io, OPERATOR); + expect(io.output).toContain('엔진에 그 직원 계정이 없습니다'); + }); + }); + + describe('퇴사 마무리', () => { + it('잠긴 계정만 — 잠기지 않았으면 하지 않는다', async () => { + const io = new ScriptedIo(['ara.kim']); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('잠긴 계정만'); + expect(order().some((call) => call.startsWith('delete:'))).toBe(false); + }); + + it('주소를 다시 쳐서 틀리면 지우지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + const io = new ScriptedIo(['ara.kim', 'y', 'ara.kim@arasystem.tes']); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('주소가 같지 않아'); + expect(order().some((call) => call.startsWith('delete:'))).toBe(false); + }); + + it('엔진 계정을 지운 뒤 우리 DB 의 그 주소 기록을 지운다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'delete:acc-ara', + `purge:${ARA}`, + ]); + expect(audit.entries[0]).toMatchObject({ + command: 'offboard', + result: 'success', + }); + }); + + it('2단계 필수의 예외 주소 · 공용 주소 구성원에서도 뺀다 — 바뀔 것에 보이고 y/N 뒤에, 세션 행은 지우지 않는다(정리 작업)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push( + { + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }, + { + id: 'l-sales', + email: `sales@${DOMAIN}`, + name: '영업', + members: [DOYUN], + }, + ); + data.isTwoFactorExempt.mockResolvedValue(true); + data.purgeAddress.mockImplementationOnce((email: string) => { + engine.calls.push(`purge:${email}`); + return Promise.resolve({ + rows: { ...NO_ROWS, loginEvents: 4 }, + exemptRemoved: true, + sessionsRevoked: 1, + filesLeftForCleanup: 0, + }); + }); + + // n 이면 아무것도 바꾸지 않는다 + const no = new ScriptedIo(['ara.kim', 'n']); + await locks.offboard(no, OPERATOR); + expect(no.output).toContain('2단계 인증 필수의 예외 주소에서 빼기'); + expect(no.output).toContain( + `공용 주소 1개의 구성원에서 빼기 — info@${DOMAIN}`, + ); + expect(no.output).toContain( + '웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다', + ); + expect(order()).toEqual([`find:${ARA}`]); + + engine.calls.length = 0; + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(order()).toEqual([ + `find:${ARA}`, + 'delete:acc-ara', + `purge:${ARA}`, + `member:l-info:${ARA}:false`, + ]); + expect(io.output).toContain('2단계 인증 필수의 예외 주소에서 뺐습니다'); + expect(audit.entries[0]).toMatchObject({ result: 'success' }); + expect(audit.entries[0].detail).toContain(`info@${DOMAIN} 구성원에서 뺌`); + expect(audit.entries[0].detail).toContain('예외 주소에서 뺌'); + }); + + it('공용 주소 구성원에서 빼지 못하면 partial — 「공용 주소」에서 빼라고 알린다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push({ + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }); + engine.failures.set( + 'setMailingListMember', + new OpsEngineError('refused'), + ); + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(io.output).toContain('「공용 주소」에서 빼 주세요'); + expect(audit.entries[0].result).toBe('partial'); + }); + + it('마지막 구성원인 공용 주소 — 바뀔 것에 "빼면 받는 사람이 없다"를 보이고 남길지 묻는다 (재리뷰 N4)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.lists.push( + { id: 'l-solo', email: `solo@${DOMAIN}`, name: '혼자', members: [ARA] }, + { + id: 'l-info', + email: `info@${DOMAIN}`, + name: '안내', + members: [ARA, DOYUN], + }, + ); + + // n — 남긴다 + const keep = new ScriptedIo(['ara.kim', 'n', 'y', ARA]); + await locks.offboard(keep, OPERATOR); + expect(keep.output).toContain( + `빼면 받는 사람이 없습니다: solo@${DOMAIN}`, + ); + expect(keep.output).toContain(`남김: solo@${DOMAIN} — 마지막 구성원`); + expect(order()).toContain(`member:l-info:${ARA}:false`); + expect(order()).not.toContain(`member:l-solo:${ARA}:false`); + + // y — 뺀다(바뀔 것에 마지막 구성원이라고 보인다) + engine.calls.length = 0; + engine.employees.set(ARA, employee({ locked: true })); + const remove = new ScriptedIo(['ara.kim', 'y', 'y', ARA]); + await locks.offboard(remove, OPERATOR); + expect(remove.output).toContain( + `solo@${DOMAIN} — 마지막 구성원이다(빼면 받는 사람이 없다)`, + ); + expect(order()).toContain(`member:l-solo:${ARA}:false`); + }); + + it('엔진 계정을 지웠는지 모르면(엔진 응답 없음) DB 를 건드리지 않고 "결과를 모릅니다 — 다시"로 알린다 (재리뷰 N3)', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.failures.set( + 'deleteAccount', + new OpsEngineError('unavailable', 'network'), + ); + const io = new ScriptedIo(['ara.kim', 'y', ARA]); + await locks.offboard(io, OPERATOR); + expect(order()).not.toContain(`purge:${ARA}`); + expect(io.output).toContain('엔진 계정을 지웠는지 모릅니다'); + expect(io.output).not.toContain('아무것도 바꾸지 않았습니다'); + }); + + it('엔진 계정 지우기가 실패하면 DB 를 건드리지 않는다', async () => { + engine.employees.set(ARA, employee({ locked: true })); + engine.failures.set('deleteAccount', new OpsEngineError('unavailable')); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).not.toContain(`purge:${ARA}`); + expect(audit.entries[0].result).toBe('failure'); + }); + + it('엔진 계정이 이미 없고 기록만 남았으면 기록만 지운다', async () => { + engine.employees.delete(ARA); + data.countRowsOf.mockResolvedValueOnce({ ...NO_ROWS, loginEvents: 2 }); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toEqual([`find:${ARA}`, `purge:${ARA}`]); + }); + + it('엔진 계정도 남은 것도 없으면 할 것이 없다 — 예외 주소만 남았어도 지운다', async () => { + engine.employees.delete(ARA); + const nothing = new ScriptedIo(['ara.kim']); + await locks.offboard(nothing, OPERATOR); + expect(nothing.output).toContain('남은 기록도 없습니다'); + + data.isTwoFactorExempt.mockResolvedValueOnce(true); + await locks.offboard(new ScriptedIo(['ara.kim', 'y', ARA]), OPERATOR); + expect(order()).toContain(`purge:${ARA}`); + }); + }); + + describe('링크 끊기 · 공용 주소 · 정책', () => { + it('신고된 주소의 링크를 운영자 끊기로 — 실행 기록에는 링크 id 만(주소 · 토큰은 남기지 않는다)', async () => { + const token = 'A'.repeat(43); + sharing.findForOperator.mockResolvedValueOnce({ + link: { + id: 'link-1', + ownerEmail: ARA, + senderName: '김아라', + sentAt: new Date('2026-10-30T06:00:00Z'), + expiresAt: new Date('2026-11-29T14:59:59Z'), + revokedAt: null, + revokedBy: null, + }, + files: [{ name: '도면.dwg', size: 2048, downloadCount: 1 }], + }); + const io = new ScriptedIo([`https://files.${DOMAIN}/s/${token}`, 'y']); + await admin.revokeLink(io, OPERATOR); + expect(sharing.revokeAsOperator).toHaveBeenCalledWith('link-1'); + expect(audit.entries[0]).toMatchObject({ + command: 'link-revoke', + target: ARA, + result: 'success', + }); + expect(JSON.stringify(audit.entries)).not.toContain(token); + expect(io.output).not.toContain(token); + }); + + it('우리 공개 링크가 아니면 알리고 끝낸다', async () => { + const io = new ScriptedIo(['https://example.com/x']); + await admin.revokeLink(io, OPERATOR); + expect(io.output).toContain('우리 공개 링크 주소가 아니거나'); + expect(sharing.revokeAsOperator).not.toHaveBeenCalled(); + }); + + it('링크를 찾다 DB 오류가 나면 "우리 링크 아님"으로 삼키지 않는다 — 오류 로그에 남기고 알린다', async () => { + sharing.findForOperator.mockRejectedValueOnce( + new Error('connection terminated'), + ); + const io = new ScriptedIo([ + `https://files.${DOMAIN}/s/${'A'.repeat(43)}`, + ]); + await admin.revokeLink(io, OPERATOR); + expect(io.output).not.toContain('우리 공개 링크 주소가 아니거나'); + expect(io.output).toContain('오류 로그'); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + }); + + it('공용 주소 만들기 — 구성원은 엔진에 있는 직원만', async () => { + await admin.mailingLists( + new ScriptedIo(['2', 'info', '대표 문의', 'ara.kim, doyun.lee', 'y']), + OPERATOR, + ); + expect(order()).toContain(`createList:info:${ARA},${DOYUN}`); + + engine.calls.length = 0; + const io = new ScriptedIo(['2', 'sales', '영업', 'nobody']); + await admin.mailingLists(io, OPERATOR); + expect(io.output).toContain('엔진에 그 직원 계정이 없습니다'); + expect(order().some((call) => call.startsWith('createList'))).toBe(false); + }); + + it('2단계 인증 필수 켜기 — 시작일(한국 날짜)을 받아 저장한다 · 잘못된 날짜는 저장하지 않는다', async () => { + await admin.policy(new ScriptedIo(['1', '2026-12-01', 'y']), OPERATOR); + expect(settings.update).toHaveBeenCalledWith( + { twoFactor: { required: true, startDate: '2026-12-01' } }, + expect.any(Date), + ); + expect(audit.entries[0]).toMatchObject({ + command: 'policy', + result: 'success', + detail: '켜기 · 시작일 2026-12-01', + }); + + settings.update.mockClear(); + await admin.policy(new ScriptedIo(['1', '2026-02-30']), OPERATOR); + expect(settings.update).not.toHaveBeenCalled(); + }); + + it('시작일을 비우면 오늘 — 바로 적용된다고 알린다', async () => { + const io = new ScriptedIo(['1', '', 'y']); + await admin.policy(io, OPERATOR); + expect(io.output).toContain('시작일이 오늘이거나 지났다 — 바로 적용된다'); + expect(settings.update).toHaveBeenCalledWith( + { + twoFactor: { + required: true, + startDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) as string, + }, + }, + expect.any(Date), + ); + }); + + it('문의 연락처 — 한 줄 200자, 빈 값은 지우기', async () => { + await admin.policy( + new ScriptedIo(['5', 'IT 담당 홍길동 · 02-000-0000', 'y']), + OPERATOR, + ); + expect(settings.update).toHaveBeenLastCalledWith( + { supportContact: 'IT 담당 홍길동 · 02-000-0000' }, + expect.any(Date), + ); + await admin.policy(new ScriptedIo(['5', '', 'y']), OPERATOR); + expect(settings.update).toHaveBeenLastCalledWith( + { supportContact: null }, + expect.any(Date), + ); + settings.update.mockClear(); + const io = new ScriptedIo(['5', '가'.repeat(201)]); + await admin.policy(io, OPERATOR); + expect(settings.update).not.toHaveBeenCalled(); + expect(io.output).toContain('200자까지'); + }); + + it('예외 주소 넣기 · 빼기 — y/N 뒤에 행 잠금 안에서 그때의 목록에 넣고 뺀다 (재리뷰 N5)', async () => { + await admin.policy(new ScriptedIo(['3', 'ara.kim', 'y']), OPERATOR); + expect(data.changeTwoFactorExempt).toHaveBeenLastCalledWith(ARA, true); + expect(settingsValue.twoFactor.exempt).toEqual([ARA]); + await admin.policy(new ScriptedIo(['4', 'ara.kim', 'y']), OPERATOR); + expect(data.changeTwoFactorExempt).toHaveBeenLastCalledWith(ARA, false); + expect(settingsValue.twoFactor.exempt).toEqual([]); + // 설정 서비스의 통째 쓰기로 덮지 않는다 + expect(settings.update).not.toHaveBeenCalled(); + }); + + it('묻는 사이 다른 운영자가 이미 바꿨으면 바꾼 것이 없다고 알린다', async () => { + data.changeTwoFactorExempt.mockResolvedValueOnce(false); + const io = new ScriptedIo(['3', 'ara.kim', 'y']); + await admin.policy(io, OPERATOR); + expect(io.output).toContain('그사이 이미 예외가 되었습니다'); + expect(audit.entries.at(-1)?.detail).toContain('이미 그 상태'); + }); + + it('현황 — 직원마다 한 줄 묶음과 정책 · 연락처 (읽기만 — 기록하지 않는다)', async () => { + data.lastWebmailLogins.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('2단계 인증 필수 정책 꺼짐'); + expect(audit.entries).toEqual([]); + }); + }); + + describe('도구의 차례 (OpsCli)', () => { + const cli = () => new OpsCli(engine, accounts, locks, admin); + + it('운영자 이름 · 관리자 비밀번호(세 번까지)를 묻고 메뉴를 돈다 — 0 이면 끝낸다', async () => { + engine.signInResults = ['rejected', 'ok']; + const io = new ScriptedIo([OPERATOR, 'wrong', 'right', '9', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('관리자 비밀번호가 맞지 않습니다'); + expect(io.output).toContain('끝냅니다'); + expect(engine.calls.filter((call) => call === 'signIn')).toHaveLength(2); + }); + + it('비밀번호가 세 번 틀리면 들어가지 못한다(종료 코드 1)', async () => { + engine.signInResults = ['rejected', 'rejected', 'rejected']; + await expect( + cli().run(new ScriptedIo([OPERATOR, 'a', 'b', 'c'])), + ).resolves.toBe(1); + }); + + it('관리자 계정에 2단계 인증이 켜져 있으면 들어가지 못한다', async () => { + engine.signInResults = ['two-factor']; + const io = new ScriptedIo([OPERATOR, 'pw']); + await expect(cli().run(io)).resolves.toBe(1); + expect(io.output).toContain('2단계 인증이 켜져 있어'); + }); + + it('입력이 끝나면(Ctrl+D) 조용히 끝낸다', async () => { + await expect(cli().run(new ScriptedIo([OPERATOR, 'pw']))).resolves.toBe( + 0, + ); + }); + + it('표준 입력이 터미널이 아니면 비밀번호를 묻기 전에 멈춘다 — docker exec -it 로 실행하라고 알린다', async () => { + const io = new ScriptedIo([OPERATOR, 'pw'], false); + await expect(cli().run(io)).resolves.toBe(1); + expect(io.output).toContain('docker exec -it'); + expect(io.asked).toEqual([]); + expect(engine.calls).not.toContain('signIn'); + }); + + it('시험용 변수(OPS_ALLOW_NO_TTY=1)로 터미널이 아닌 입력을 받는 중이면 첫 화면에 경고 한 줄', async () => { + const io = Object.assign(new ScriptedIo([OPERATOR, 'pw', '0']), { + pipedForTesting: true, + }); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('OPS_ALLOW_NO_TTY=1 — 자동 시험용'); + }); + + it('메뉴에서 모르는 오류가 나면 오류 로그에 남기고 메뉴를 이어 간다', async () => { + jest.spyOn(admin, 'status').mockRejectedValueOnce(new TypeError('boom')); + const io = new ScriptedIo([OPERATOR, 'pw', '9', '0']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('오류 로그'); + expect(errorLogs.join('\n')).toContain('[SYS_001]'); + expect(errorLogs.join('\n')).toContain('TypeError'); + }); + + it('관리자 로그인이 끊기면(비밀번호가 바뀜) 메뉴를 끝낸다', async () => { + jest + .spyOn(engine, 'listEmployees') + .mockRejectedValueOnce(new OpsEngineError('not-signed-in')); + jest + .spyOn(admin, 'status') + .mockRejectedValueOnce(new OpsEngineError('not-signed-in')); + const io = new ScriptedIo([OPERATOR, 'pw', '9', '9']); + await expect(cli().run(io)).resolves.toBe(0); + expect(io.output).toContain('도구를 다시 시작하세요'); + }); + }); +}); diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts new file mode 100644 index 0000000..b06b8ae --- /dev/null +++ b/backend/src/modules/ops/ops-data.service.ts @@ -0,0 +1,294 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { type DataSource, type EntityManager, In } from 'typeorm'; + +import { AppPasswordUsageEntity } from '@/modules/account-security/app-password-usage.entity'; +import { LoginEventEntity } from '@/modules/account-security/login-event.entity'; +import { TwoFactorSettingEntity } from '@/modules/account-security/two-factor-setting.entity'; +import { AuthSessionRepository } from '@/modules/auth/auth-session.repository'; +import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity'; +import { + FILE_BLOB_STORE, + type FileBlobStore, +} from '@/modules/file-storage/file-blob-store'; +import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity'; +import { MailDirectoryEntryEntity } from '@/modules/mail-directory/mail-directory.entity'; +import { MailSignatureImageEntity } from '@/modules/mail-signature/mail-signature-image.entity'; +import { MailSignatureEntity } from '@/modules/mail-signature/mail-signature.entity'; +import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; +import { MailUserSettingsEntity } from '@/modules/mail-user-settings/mail-user-settings.entity'; +import { + SYSTEM_SETTINGS_ROW_ID, + SystemSettingsEntity, +} from '@/modules/system-settings/system-settings.entity'; + +/** + * 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인). + * 웹메일 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절). + * 끊긴 행은 로그인된 브라우저 목록에 보이지 않고 쓸 수도 없다 + */ +export interface AddressRows { + readonly loginEvents: number; + readonly appPasswordUsages: number; + readonly twoFactorSettings: number; + readonly signatures: number; + readonly signatureImages: number; + readonly mailSettings: number; + readonly directory: number; + readonly files: number; + readonly shareLinks: number; +} + +/** 지운 결과 — 줄 수 · 2단계 필수의 예외 주소에서 뺐는지 · 끊은 세션 수 · 지우지 못해 정리 작업에 맡긴 디스크 파일 수 */ +export interface AddressPurge { + readonly rows: AddressRows; + readonly exemptRemoved: boolean; + readonly sessionsRevoked: number; + readonly filesLeftForCleanup: number; +} + +export function totalRows(rows: AddressRows): number { + return (Object.values(rows) as number[]).reduce( + (sum, count) => sum + count, + 0, + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 예외 주소 목록에서 그 주소를 뺀 것 (대소문자 · 앞뒤 공백 무시 — 서버 설정 읽기와 같은 규칙) */ +export function exemptWithout( + exempt: unknown, + email: string, +): { exempt: string[]; removed: boolean } { + const entries = Array.isArray(exempt) + ? exempt.filter((entry): entry is string => typeof entry === 'string') + : []; + const kept = entries.filter( + (entry) => entry.trim().toLowerCase() !== email.trim().toLowerCase(), + ); + return { exempt: kept, removed: kept.length !== entries.length }; +} + +/** + * 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리. + * 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로 + * 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다. + */ +@Injectable() +export class OpsDataService { + private readonly logger = new Logger(OpsDataService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly sessions: AuthSessionRepository, + @Inject(FILE_BLOB_STORE) private readonly blobs: FileBlobStore, + private readonly signatureImages: SignatureImageStorage, + ) {} + + /** + * 그 직원의 웹메일 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts) + */ + revokeSessions(email: string): Promise { + return this.sessions.revokeAllOf( + email, + 'ended-by-operator', + new Date(), + null, + ); + } + + /** 웹메일에서 2단계 인증을 켠 때 기록을 지운다 (2단계 해제 — 엔진이 기준이고 이 기록은 켠 때뿐이다) */ + async forgetTwoFactor(email: string): Promise { + await this.dataSource.manager.delete(TwoFactorSettingEntity, { + userEmail: email, + }); + } + + /** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */ + async lastWebmailLogins( + emails: readonly string[], + ): Promise> { + if (emails.length === 0) return new Map(); + const rows = await this.dataSource.manager + .createQueryBuilder(LoginEventEntity, 'event') + .select('event.user_email', 'email') + .addSelect('MAX(event.occurred_at)', 'last') + .where({ userEmail: In([...emails]), method: 'webmail', isSuccess: true }) + .groupBy('event.user_email') + .getRawMany<{ email: string; last: Date | string }>(); + return new Map( + rows.map((row) => [ + row.email, + row.last instanceof Date ? row.last : new Date(row.last), + ]), + ); + } + + /** 그 주소로 남은 줄 수 */ + async countRowsOf(email: string): Promise { + return this.rowsOf(this.dataSource.manager, email, 'count'); + } + + /** 그 주소가 2단계 인증 필수의 예외 주소 목록에 있는가 (서버 설정 표 — 기억 없이 읽는다) */ + async isTwoFactorExempt(email: string): Promise { + const row = await this.dataSource.manager.findOneBy(SystemSettingsEntity, { + id: SYSTEM_SETTINGS_ROW_ID, + }); + return exemptWithout(row?.twoFactorExempt, email).removed; + } + + /** + * 퇴사 마무리 — 살아 있는 세션을 끊고(행은 남긴다), 그 주소의 줄을 한 트랜잭션에 지운다 + * (로그인 기록 · 앱 비밀번호 사용 · 2단계 기록 · 서명 · 서명 그림 · 설정 · 사내 목록 기록 · 대용량 파일 · 링크 + 2단계 필수의 예외 주소에서 빼기). + * 지운 뒤 디스크의 파일(대용량 파일 · 서명 그림)도 지운다 — 지우지 못한 것은 정리 작업이 표에 없는 파일로 지운다 + */ + async purgeAddress(email: string): Promise { + // 디스크에서 지울 파일 — 줄을 지우기 전에 이름을 읽어 둔다 + const fileIds = ( + await this.dataSource.manager.find(StoredFileEntity, { + select: { id: true }, + where: { ownerEmail: email }, + }) + ).map((file) => file.id); + const imageNames = ( + await this.dataSource.manager.find(MailSignatureImageEntity, { + select: { fileName: true }, + where: { userEmail: email }, + }) + ).map((image) => image.fileName); + + // 살아 있는 세션은 끊는다 — 행은 지우지 않는다: 끊은 기록을 서버(다른 프로세스)가 15초 동기화로 읽어 남은 액세스 토큰을 막는다(계약 6절) + const sessionsRevoked = await this.revokeSessions(email); + const now = new Date(); + const { rows, exemptRemoved } = await this.dataSource.transaction( + async (manager) => ({ + rows: await this.rowsOf(manager, email, 'delete'), + exemptRemoved: await this.removeExempt(manager, email, now), + }), + ); + + let filesLeftForCleanup = 0; + for (const id of fileIds) { + try { + await this.blobs.remove(id); + } catch (error) { + filesLeftForCleanup += 1; + this.logger.warn( + `[SYS_001] 퇴사 마무리 — 대용량 파일을 디스크에서 지우지 못해 정리 작업에 맡깁니다: id=${id}, reason=${errorMessage(error)}`, + ); + } + } + for (const name of imageNames) { + try { + await this.signatureImages.remove(name); + } catch (error) { + filesLeftForCleanup += 1; + this.logger.warn( + `[SYS_001] 퇴사 마무리 — 서명 그림을 디스크에서 지우지 못해 정리 작업에 맡깁니다: reason=${errorMessage(error)}`, + ); + } + } + return { rows, exemptRemoved, sessionsRevoked, filesLeftForCleanup }; + } + + /** + * 2단계 인증 필수의 예외 주소에 넣거나 뺀다 — 서버 설정 줄을 잠그고 읽고 쓴다(운영 도구 둘이 함께 바꿔도 잃지 않게 · 퇴사 마무리와 같은 잠금) → + * 바꿨으면 true, 이미 그 상태면 false (재리뷰 N5) + */ + async changeTwoFactorExempt( + email: string, + include: boolean, + ): Promise { + const now = new Date(); + return this.dataSource.transaction(async (manager) => { + if (!include) return this.removeExempt(manager, email, now); + const row = await manager.findOne(SystemSettingsEntity, { + where: { id: SYSTEM_SETTINGS_ROW_ID }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row) { + throw new Error( + 'system_settings 줄이 없습니다 — 마이그레이션을 확인하세요', + ); + } + const { exempt, removed: present } = exemptWithout( + row.twoFactorExempt, + email, + ); + if (present) return false; + await manager.update( + SystemSettingsEntity, + { id: SYSTEM_SETTINGS_ROW_ID }, + { twoFactorExempt: [...exempt, email], updatedAt: now }, + ); + return true; + }); + } + + /** 2단계 인증 필수의 예외 주소에서 뺀다 → 뺐으면 true (서버 설정 줄을 잠그고 — 운영 도구 둘이 함께 바꿔도 잃지 않게) */ + private async removeExempt( + manager: EntityManager, + email: string, + now: Date, + ): Promise { + const row = await manager.findOne(SystemSettingsEntity, { + where: { id: SYSTEM_SETTINGS_ROW_ID }, + lock: { mode: 'pessimistic_write' }, + }); + if (!row) return false; + const { exempt, removed } = exemptWithout(row.twoFactorExempt, email); + if (!removed) return false; + await manager.update( + SystemSettingsEntity, + { id: SYSTEM_SETTINGS_ROW_ID }, + { twoFactorExempt: exempt, updatedAt: now }, + ); + return true; + } + + /** 표마다 세거나 지운다 — 같은 차례 · 같은 조건 */ + private async rowsOf( + manager: EntityManager, + email: string, + mode: 'count' | 'delete', + ): Promise { + const run = async ( + entity: + | typeof LoginEventEntity + | typeof AppPasswordUsageEntity + | typeof TwoFactorSettingEntity + | typeof MailSignatureEntity + | typeof MailSignatureImageEntity + | typeof MailUserSettingsEntity + | typeof MailDirectoryEntryEntity + | typeof StoredFileEntity + | typeof ShareLinkEntity, + where: Record, + ): Promise => { + if (mode === 'count') return manager.countBy(entity, where); + return (await manager.delete(entity, where)).affected ?? 0; + }; + return { + loginEvents: await run(LoginEventEntity, { userEmail: email }), + appPasswordUsages: await run(AppPasswordUsageEntity, { + userEmail: email, + }), + twoFactorSettings: await run(TwoFactorSettingEntity, { + userEmail: email, + }), + signatures: await run(MailSignatureEntity, { userEmail: email }), + signatureImages: await run(MailSignatureImageEntity, { + userEmail: email, + }), + mailSettings: await run(MailUserSettingsEntity, { userEmail: email }), + directory: await run(MailDirectoryEntryEntity, { email }), + // 조각 줄은 외래 키(CASCADE)로 함께 지워진다 + files: await run(StoredFileEntity, { ownerEmail: email }), + // 링크의 파일 줄은 외래 키(CASCADE)로 함께 지워진다 + shareLinks: await run(ShareLinkEntity, { ownerEmail: email }), + }; + } +} diff --git a/backend/src/modules/ops/ops-engine.port.ts b/backend/src/modules/ops/ops-engine.port.ts new file mode 100644 index 0000000..355da75 --- /dev/null +++ b/backend/src/modules/ops/ops-engine.port.ts @@ -0,0 +1,151 @@ +/** + * 운영 도구의 엔진 포트 (13단계 — 계약 6절 "운영 도구") — 명령 로직은 이 인터페이스만 안다. + * 지금 구현은 Stalwart — `modules/mail/jmap/jmap-ops-engine.ts`. + * - 관리자 Basic(실행할 때 받은 비밀번호 — 저장하지 않는다)은 계정 관리 API(`x:Account` · `x:MailingList` · 계정을 만들 도메인)에만 쓴다 + * - 직원의 앱 비밀번호 · Sieve(받는 메일 넘기기 · 자동 응답)는 웹메일과 같은 게이트웨이 대리 접근으로 한다 — 관리자 계정에서는 대리 접근 권한(impersonate)을 뺀다(인프라) + * 실패는 BusinessException 으로 던진다 — 엔진의 거절은 context 에 종류(jmapType)만 싣는다(설명 문장 · 비밀번호는 넣지 않는다) + */ +export const OPS_ENGINE = Symbol('OPS_ENGINE'); + +/** 관리자 로그인 결과 */ +export type OpsSignInResult = 'ok' | 'rejected' | 'two-factor' | 'not-admin'; + +/** 엔진의 직원 계정 하나 (현황 · 명령의 대상) */ +export interface EngineEmployee { + /** 엔진의 계정 id — 불투명 */ + readonly id: string; + /** 주소 (소문자) */ + readonly email: string; + /** 표시 이름 — 없으면 주소의 @ 앞 */ + readonly name: string; + /** 잠김 — 로그인 권한(authenticate)을 뺐다 */ + readonly locked: boolean; + /** 계정 권한이 직접 정해진(Replace) 계정 — 잠그고 푸는 것을 이 도구가 하지 않는다 */ + readonly customPermissions: boolean; + /** 비밀번호가 만료됐다(임시 비밀번호 — 첫 로그인 때 바꾼다) */ + readonly passwordExpired: boolean; + /** 2단계 인증(OTP)이 켜져 있다 */ + readonly twoFactorEnabled: boolean; + /** 앱 비밀번호 수 */ + readonly appPasswords: number; + /** 메일함 사용량 (바이트) — 모르면 null */ + readonly usedBytes: number | null; + /** 메일함 용량 한도 (바이트) — 없으면 null(무제한) */ + readonly quotaBytes: number | null; +} + +/** 공용 주소(MailingList) — 구성원 받은편지함으로 복사한다 */ +export interface EngineMailingList { + readonly id: string; + readonly email: string; + readonly name: string; + /** 구성원 주소 (소문자) */ + readonly members: readonly string[]; +} + +/** 잠글 때 거는 Sieve — 받는 메일 넘기기(사본) · 자동 응답 */ +export interface LockRules { + /** 사본을 받을 사내 주소 — 넘기지 않으면 null */ + readonly forwardTo: string | null; + /** 자동 응답 — 걸지 않으면 null */ + readonly autoReply: { + readonly subject: string; + readonly body: string; + } | null; +} + +/** 지금 걸린 잠금 Sieve */ +export interface LockRulesState { + readonly forwardTo: string | null; + readonly autoReply: boolean; +} + +export interface OpsEngine { + /** 관리자 로그인 — 맞으면 이 프로세스가 끝날 때까지 메모리에만 둔다 */ + signIn(adminPassword: string): Promise; + + /** + * 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다). + * 아니면 명령은 "직원 주소가 아님"으로 멈춘다 — "엔진에 없음"과 가른다(없음은 기록만 남은 주소의 정리로 이어진다) + */ + isEmployeeAddress(email: string): boolean; + /** 주소로 직원 계정 찾기 (직원 주소가 아니거나 · User 가 아닌 계정(Group)이거나 · 없으면 null) */ + findEmployee(email: string): Promise; + /** 직원 계정 모두 (주소 차례 — User 계정 가운데 직원 주소만) */ + listEmployees(): Promise; + /** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */ + addressInUse(email: string): Promise; + + /** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */ + createEmployee(input: { + readonly localPart: string; + readonly name: string; + readonly quotaBytes: number | null; + readonly tempPassword: string; + }): Promise; + /** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */ + resetPassword(id: string, tempPassword: string): Promise; + /** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */ + removeTwoFactor(id: string): Promise; + /** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */ + setLoginEnabled(id: string, enabled: boolean): Promise; + /** 계정 지우기 — 그 계정의 메일이 영구 삭제된다 */ + deleteAccount(id: string): Promise; + + listMailingLists(): Promise; + createMailingList(input: { + readonly localPart: string; + readonly name: string; + readonly members: readonly string[]; + }): Promise; + setMailingListMember( + id: string, + member: string, + included: boolean, + ): Promise; + + /** 앱 비밀번호를 모두 지운다 → 지운 수 (대리 접근 — 잠겼거나 비밀번호가 만료된 계정은 엔진이 막는다) */ + deleteAppPasswords(email: string): Promise; + /** 지금 걸린 잠금 Sieve (대리 접근) */ + getLockRules(email: string): Promise; + /** 잠금 Sieve 를 건다 — 직원의 필터 · 부재중 · 차단과 한 스크립트(엔진은 스크립트 하나만 켠다) */ + applyLockRules(email: string, rules: LockRules): Promise; + /** 잠금 Sieve 를 뗀다 — 넘기기 규칙을 빼고 자동 응답을 끈다 */ + clearLockRules(email: string): Promise; +} + +/** 관리자 계정 주소 — `@` (운영 모듈이 설정에서 만든다) */ +export const OPS_ADMIN_ACCOUNT = Symbol('OPS_ADMIN_ACCOUNT'); + +/** 잠글 때 넣는 넘기기 규칙의 이름 — 풀 때 이 이름의 규칙을 찾아 뗀다 (필터 이름 50자 안) */ +export const LOCK_FORWARD_RULE_NAME = '잠금 — 받는 메일 넘기기 (운영 도구)'; + +/** 엔진이 받지 않은 까닭 — 명령이 한국어로 알린다 */ +export type OpsEngineErrorKind = + /** 엔진이 임시 비밀번호를 약하다고 받지 않음 — 새로 만들어 다시 한다 */ + | 'weak-password' + /** 같은 이름(주소)이 이미 있다 */ + | 'exists' + /** 대상이 없다 */ + | 'not-found' + /** 계정 권한이 직접 정해져 이 도구가 바꾸지 않는다 */ + | 'custom-permissions' + /** 필터가 이미 가득하다(50개) — 넘기기 규칙을 더할 수 없다 */ + | 'too-many-rules' + /** 관리자로 들어가지 않았다 · 관리자 비밀번호가 그사이 바뀌었다 */ + | 'not-signed-in' + /** 그 밖의 거절 */ + | 'refused' + /** 엔진에 닿지 못함 · 응답을 읽지 못함 */ + | 'unavailable'; + +/** 엔진의 거절 — detail 은 엔진 오류의 종류(type)만(설명 문장 · 비밀번호는 넣지 않는다) */ +export class OpsEngineError extends Error { + constructor( + readonly kind: OpsEngineErrorKind, + readonly detail: string | null = null, + ) { + super(`ops-engine:${kind}${detail ? `/${detail}` : ''}`); + this.name = 'OpsEngineError'; + } +} diff --git a/backend/src/modules/ops/ops-helpers.spec.ts b/backend/src/modules/ops/ops-helpers.spec.ts new file mode 100644 index 0000000..42d7469 --- /dev/null +++ b/backend/src/modules/ops/ops-helpers.spec.ts @@ -0,0 +1,223 @@ +import { PassThrough } from 'stream'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { OpsEngineError } from './ops-engine.port'; +import { OpsInputClosed, TerminalIo, isYes } from './ops-io'; +import { + failureDetail, + failureText, + toAddress, + toDisplayName, + toQuotaBytes, +} from './ops-text'; +import { TEMP_PASSWORD_LENGTH, generateTempPassword } from './temp-password'; + +describe('임시 비밀번호 (13단계)', () => { + it('16자 · 헷갈리는 글자 없음 · 대문자 · 소문자 · 숫자가 하나씩은 있다', () => { + for (let index = 0; index < 200; index += 1) { + const password = generateTempPassword(); + expect(password).toHaveLength(TEMP_PASSWORD_LENGTH); + expect(password).toMatch(/^[A-HJ-NP-Za-km-np-z2-9]+$/); + expect(password).toMatch(/[A-Z]/); + expect(password).toMatch(/[a-z]/); + expect(password).toMatch(/\d/); + } + }); + + it('세 가지가 섞이지 않은 뽑기는 버리고 다시 뽑는다', () => { + // 처음 16번은 늘 첫 글자(A) — 대문자뿐이라 버려진다. 그다음은 차례로 돈다 + let calls = 0; + const pick = (max: number) => + calls++ < TEMP_PASSWORD_LENGTH ? 0 : calls % max; + const password = generateTempPassword(pick); + expect(calls).toBeGreaterThan(TEMP_PASSWORD_LENGTH); + expect(password).toMatch(/[a-z]/); + }); +}); + +describe('운영 도구의 값 읽기 · 글', () => { + it('주소 — @도메인 을 붙이고 소문자로, 다른 도메인 · 모양이 틀린 것은 null', () => { + expect(toAddress('Hong.Gildong', 'arasystem.test')).toBe( + 'hong.gildong@arasystem.test', + ); + expect(toAddress(' ara.kim@ARASYSTEM.test ', 'arasystem.test')).toBe( + 'ara.kim@arasystem.test', + ); + for (const input of [ + '', + 'a@example.com', + '.hong', + 'hong.', + 'ho..ng', + 'hong gildong', + '홍길동', + 'a'.repeat(65), + 'a@b@arasystem.test', + ]) { + expect(toAddress(input, 'arasystem.test')).toBeNull(); + } + }); + + it('이름 — 한 줄 1~100자', () => { + expect(toDisplayName(' 홍길동 ')).toBe('홍길동'); + expect(toDisplayName('')).toBeNull(); + expect(toDisplayName('가'.repeat(101))).toBeNull(); + expect(toDisplayName('홍\n길동')).toBeNull(); + }); + + it('메일함 용량(GB) — 빈 값은 무제한, 소수 한 자리까지, 0 · 음수 · 너무 큰 값은 invalid', () => { + expect(toQuotaBytes('')).toBeNull(); + expect(toQuotaBytes('1')).toBe(1024 ** 3); + expect(toQuotaBytes('2.5')).toBe(2.5 * 1024 ** 3); + for (const input of ['0', '-1', '1.25', '1e3', 'abc', '10001']) { + expect(toQuotaBytes(input)).toBe('invalid'); + } + }); + + it('실패의 글 — 엔진의 까닭 · 코드만(설명 문장 없이)', () => { + expect(failureText(new OpsEngineError('exists'))).toContain( + '이미 있습니다', + ); + expect(failureText(new OpsEngineError('not-signed-in'))).toContain( + '도구를 다시 시작하세요', + ); + expect(failureText(new OpsEngineError('refused', 'overQuota'))).toContain( + 'overQuota', + ); + expect(failureText(new BusinessException('MAIL_001'))).toContain( + '메일 엔진', + ); + expect( + failureDetail( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ), + ).toBe('MAIL_001/timeout'); + expect(failureDetail(new OpsEngineError('refused', 'forbidden'))).toBe( + 'engine:refused/forbidden', + ); + }); + + it('y/N — y · yes · 예 · ㅇ 만 예', () => { + for (const yes of ['y', 'Y', 'yes', '예', 'ㅇ', ' y ']) { + expect(isYes(yes)).toBe(true); + } + for (const no of ['', 'n', 'no', '아니오', 'yy']) { + expect(isYes(no)).toBe(false); + } + }); +}); + +describe('TerminalIo — 터미널 입출력', () => { + function setup() { + const input = new PassThrough(); + const output = new PassThrough(); + let written = ''; + output.on('data', (chunk: Buffer) => { + written += chunk.toString('utf8'); + }); + const io = new TerminalIo(input, output); + return { input, io, written: () => written }; + } + + it('한꺼번에 들어온 줄(붙여 넣기 · 파이프)도 잃지 않고 차례로 꺼낸다', async () => { + const { input, io } = setup(); + input.write('운영자\n비밀\n9\n'); + await expect(io.ask('이름: ')).resolves.toBe('운영자'); + await expect(io.askSecret('비밀번호: ')).resolves.toBe('비밀'); + await expect(io.ask('번호: ')).resolves.toBe('9'); + io.close(); + }); + + it('비밀은 앞뒤 공백도 그대로 · y/N 은 y 일 때만 예', async () => { + const { input, io } = setup(); + const secret = io.askSecret('비밀번호: '); + input.write(' pass word \n'); + await expect(secret).resolves.toBe(' pass word '); + const yes = io.confirm('할까요?'); + input.write('y\n'); + await expect(yes).resolves.toBe(true); + const no = io.confirm('할까요?'); + input.write('\n'); + await expect(no).resolves.toBe(false); + io.close(); + }); + + it('입력이 끝나면 기다리던 물음과 다음 물음이 OpsInputClosed 로 끝난다', async () => { + const { input, io } = setup(); + const waiting = io.ask('이름: '); + input.end(); + await expect(waiting).rejects.toBeInstanceOf(OpsInputClosed); + await expect(io.ask('또: ')).rejects.toBeInstanceOf(OpsInputClosed); + }); + + it('보이는 줄과 물음은 출력에 쓴다', async () => { + const { input, io, written } = setup(); + io.print('안녕'); + const answer = io.ask('이름: '); + input.write('a\n'); + await answer; + expect(written()).toContain('안녕\n'); + expect(written()).toContain('이름: '); + io.close(); + }); + + it('터미널이 아니면 interactive 가 아니다 — 자동 시험만 allowNonInteractive(OPS_ALLOW_NO_TTY=1)로 연다', () => { + const { io } = setup(); + expect(io.interactive).toBe(false); + io.close(); + + const piped = new TerminalIo(new PassThrough(), new PassThrough(), { + allowNonInteractive: true, + }); + expect(piped.interactive).toBe(true); + piped.close(); + + const terminal = Object.assign(new PassThrough(), { isTTY: true }); + const tty = new TerminalIo(terminal, new PassThrough()); + expect(tty.interactive).toBe(true); + tty.close(); + }); +}); + +describe('TerminalIo — 터미널(TTY)에서 비밀번호 (리뷰 H1)', () => { + const SECRET = 'S3cretAdminPw'; + + function terminal() { + const input = Object.assign(new PassThrough(), { isTTY: true }); + const output = new PassThrough(); + let written = ''; + output.on('data', (chunk: Buffer) => { + written += chunk.toString('utf8'); + }); + const io = new TerminalIo(input, output); + return { input, io, written: () => written }; + } + + it('친 비밀번호는 화면에 되비치지 않고, 입력 기록에도 남지 않아 ↑ 로 다시 그려지지 않는다', async () => { + const { input, io, written } = terminal(); + const secret = io.askSecret('비밀번호: '); + input.write(`${SECRET}\r`); + await expect(secret).resolves.toBe(SECRET); + expect(written()).not.toContain(SECRET); + + // ↑ 다음에 친 줄 — 기록이 있었다면 ↑ 가 비밀번호를 불러와 그 뒤에 9 가 붙고 화면에도 그려졌다 + const next = io.ask('번호: '); + input.write('\x1b[A'); + input.write('9\r'); + await expect(next).resolves.toBe('9'); + expect(written()).not.toContain(SECRET); + io.close(); + }); + + it('보통 줄도 기록에 남기지 않는다 — ↑ 는 아무것도 불러오지 않는다', async () => { + const { input, io } = terminal(); + const first = io.ask('번호: '); + input.write('10\r'); + await expect(first).resolves.toBe('10'); + const second = io.ask('번호: '); + input.write('\x1b[A'); + input.write('\r'); + await expect(second).resolves.toBe(''); + io.close(); + }); +}); diff --git a/backend/src/modules/ops/ops-io.ts b/backend/src/modules/ops/ops-io.ts new file mode 100644 index 0000000..f48a04b --- /dev/null +++ b/backend/src/modules/ops/ops-io.ts @@ -0,0 +1,148 @@ +import { createInterface, type Interface } from 'readline'; +import { Writable } from 'stream'; + +/** + * 운영 도구의 묻기 · 보이기 (13단계) — 명령 로직은 이 인터페이스만 안다(시험은 흉내를 넣는다). + * 화면에 쓰는 것은 이것뿐이다 — 비밀(관리자 비밀번호)은 askSecret 으로 받아 화면에 보이지 않는다. + * 임시 비밀번호는 print 로 한 번만 보이고 어디에도 남기지 않는다 + */ +export interface OpsIo { + /** + * 사람이 터미널에서 돌리는가 — 표준 입력이 TTY 일 때만 true. 아니면 도구는 비밀번호를 묻기 전에 멈춘다 + * (파이프로 흘려 넣은 비밀번호는 셸 기록 · 프로세스 목록에 남을 수 있다). 자동 시험만 `OPS_ALLOW_NO_TTY=1` 로 연다 + */ + readonly interactive: boolean; + /** 터미널이 아닌 입력을 시험용 변수(`OPS_ALLOW_NO_TTY=1`)로 받는 중인가 — 도구가 첫 화면에 경고 한 줄을 보인다 */ + readonly pipedForTesting?: boolean; + /** 한 줄 보이기 (빈 값이면 빈 줄) */ + print(line?: string): void; + /** 한 줄 묻기 — 앞뒤 공백을 지운 값 */ + ask(question: string): Promise; + /** 화면에 보이지 않게 묻기 (관리자 비밀번호) — 지우지 않은 그대로 */ + askSecret(question: string): Promise; + /** y/N — y · yes · 예 · ㅇ 만 예, 그 밖(빈 값 포함)은 아니오 */ + confirm(question: string): Promise; + close(): void; +} + +/** 입력이 끝났다(Ctrl+D · Ctrl+C · 파이프 끝) — 도구를 끝낸다 */ +export class OpsInputClosed extends Error { + constructor() { + super('입력이 끝났습니다.'); + this.name = 'OpsInputClosed'; + } +} + +const YES = new Set(['y', 'yes', '예', 'ㅇ']); + +/** y/N 답 → 예인가 */ +export function isYes(answer: string): boolean { + return YES.has(answer.trim().toLowerCase()); +} + +interface Waiter { + readonly resolve: (line: string) => void; + readonly reject: (error: Error) => void; +} + +/** + * 터미널이 아닌 표준 입력(파이프)도 받게 하는 환경 변수 — 값이 `1` 일 때만. 자동 시험(`docker exec -i -e OPS_ALLOW_NO_TTY=1 …`)용이고 사람이 쓰지 않는다 + */ +export const OPS_ALLOW_NO_TTY_ENV = 'OPS_ALLOW_NO_TTY'; + +/** 터미널이 아닌 입력(파이프)도 받을지 — `OPS_ALLOW_NO_TTY=1` 일 때만 */ +export interface TerminalIoOptions { + readonly allowNonInteractive?: boolean; +} + +/** + * 터미널 입출력 — `docker exec -it node dist/cli/ops.js`. + * - 들어온 줄은 차례로 쌓아 두고 물을 때 하나씩 꺼낸다 — 붙여 넣거나 파이프로 한꺼번에 들어온 줄도 잃지 않는다 + * - 비밀은 출력 흐름을 잠깐 막아(muted) 친 글자가 화면에 되비치지 않게 받는다 + * - **입력 기록(history)을 두지 않는다**(`historySize: 0`) — 두면 비밀번호 줄이 기록에 남아 ↑ 한 번에 화면에 평문으로 다시 그려진다 + * (2026-11-02 리뷰). 메뉴 번호 · 주소를 다시 부를 일은 없다 + * - console 은 쓰지 않는다(린트 규칙) — 표준 출력에 직접 쓴다 + */ +export class TerminalIo implements OpsIo { + readonly interactive: boolean; + readonly pipedForTesting: boolean; + private muted = false; + private closed = false; + private readonly rl: Interface; + private readonly lines: string[] = []; + private readonly waiters: Waiter[] = []; + + constructor( + private readonly input: NodeJS.ReadableStream = process.stdin, + private readonly output: NodeJS.WritableStream = process.stdout, + options: TerminalIoOptions = {}, + ) { + const isTerminal = (this.input as { isTTY?: boolean }).isTTY === true; + this.interactive = isTerminal || options.allowNonInteractive === true; + this.pipedForTesting = !isTerminal && options.allowNonInteractive === true; + // 터미널이 되비치는 글자(친 글자)만 이 흐름을 지난다 — 비밀을 묻는 동안은 버린다 + const echo = new Writable({ + write: (chunk: Buffer | string, _encoding, callback) => { + if (!this.muted) this.output.write(chunk); + callback(); + }, + }); + this.rl = createInterface({ + input: this.input, + output: echo, + terminal: isTerminal, + // 입력 기록 없음 — 비밀번호 줄이 ↑ 로 다시 그려지지 않게 (위 머리말) + historySize: 0, + }); + this.rl.setPrompt(''); + this.rl.on('line', (line: string) => { + const waiter = this.waiters.shift(); + if (waiter) waiter.resolve(line); + else this.lines.push(line); + }); + this.rl.on('close', () => { + this.closed = true; + for (const waiter of this.waiters.splice(0)) { + waiter.reject(new OpsInputClosed()); + } + }); + } + + print(line = ''): void { + this.output.write(`${line}\n`); + } + + async ask(question: string): Promise { + this.output.write(question); + return (await this.nextLine()).trim(); + } + + async askSecret(question: string): Promise { + this.output.write(question); + this.muted = true; + try { + return await this.nextLine(); + } finally { + this.muted = false; + this.output.write('\n'); + } + } + + async confirm(question: string): Promise { + this.output.write(`${question} (y/N) `); + return isYes(await this.nextLine()); + } + + close(): void { + this.rl.close(); + } + + private nextLine(): Promise { + const queued = this.lines.shift(); + if (queued !== undefined) return Promise.resolve(queued); + if (this.closed) return Promise.reject(new OpsInputClosed()); + return new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } +} diff --git a/backend/src/modules/ops/ops-prompts.ts b/backend/src/modules/ops/ops-prompts.ts new file mode 100644 index 0000000..22325e4 --- /dev/null +++ b/backend/src/modules/ops/ops-prompts.ts @@ -0,0 +1,128 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { + OpsEngineError, + type EngineEmployee, + type OpsEngine, +} from './ops-engine.port'; +import type { OpsIo } from './ops-io'; +import { failureDetail, failureText, toAddress } from './ops-text'; + +/** 운영 도구의 공통 묻기 · 보이기 — 명령들이 함께 쓴다 */ + +const logger = new Logger('OpsTool'); + +/** + * 모르는 오류(엔진 · 업무 오류가 아닌 것 — DB · 프로그램 오류)는 오류 로그에 남긴다 — 이 창의 ERROR 줄. + * 운영자에게는 짧은 글만 보이므로 원인을 여기서 본다. 종류와 스택만이다(비밀번호는 오류에 실리지 않는다) + */ +export function logUnknownFailure(error: unknown): void { + if (error instanceof OpsEngineError || error instanceof BusinessException) { + return; + } + logger.error( + `[SYS_001] 운영 도구에서 모르는 오류: ${failureDetail(error)}`, + error instanceof Error ? error.stack : undefined, + ); +} + +/** 실패 → 운영자에게 보일 글 — 모르는 오류는 오류 로그에도 남긴다 (명령은 failureText 대신 이것을 쓴다) */ +export function failureLine(error: unknown): string { + logUnknownFailure(error); + return failureText(error); +} + +/** 구분선 */ +export const RULE = '─'.repeat(48); + +/** 주소를 묻는다 — `@도메인` 은 붙이지 않아도 된다. 모양이 틀리면 알리고 null */ +export async function askAddress( + io: OpsIo, + domain: string, + label: string, +): Promise { + const input = await io.ask( + `${label} (예: hong.gildong — @${domain} 는 붙이지 않아도 됩니다): `, + ); + const email = toAddress(input, domain); + if (email === null) { + io.print(`주소 모양이 맞지 않거나 @${domain} 주소가 아닙니다.`); + } + return email; +} + +/** 직원 주소가 아닐 때의 안내 (관리자 · 게이트웨이 · 사내 목록 · 막은 계정) */ +export const NOT_EMPLOYEE_ADDRESS = + '직원 주소가 아닙니다 — 관리자 · 게이트웨이 · 사내 목록 · 막은 계정은 이 도구로 다루지 않습니다.'; +/** 그 주소의 계정 · 목록이 직원 계정이 아닐 때의 안내 (공용 주소 · 공유 메일함) */ +export const NOT_EMPLOYEE_ACCOUNT = + '그 주소는 직원 계정이 아닙니다(공용 주소 · 공유 메일함) — 이 도구로 다루지 않습니다.'; + +/** + * 직원 주소를 묻고 엔진에서 찾는다 — 직원 주소가 아님 · 직원 계정이 아님(공용 주소 · 공유 메일함) · 엔진에 없음을 갈라 알리고 null + */ +export async function askEmployee( + io: OpsIo, + engine: OpsEngine, + domain: string, + label = '직원 주소', +): Promise { + const email = await askAddress(io, domain, label); + if (email === null) return null; + if (!engine.isEmployeeAddress(email)) { + io.print(NOT_EMPLOYEE_ADDRESS); + return null; + } + try { + const employee = await engine.findEmployee(email); + if (employee) return employee; + io.print( + (await engine.addressInUse(email)) + ? NOT_EMPLOYEE_ACCOUNT + : '엔진에 그 직원 계정이 없습니다.', + ); + return null; + } catch (error) { + io.print(failureLine(error)); + return null; + } +} + +/** 바뀔 것을 보이고 y/N 으로 묻는다 */ +export async function confirmPlan( + io: OpsIo, + title: string, + lines: readonly string[], +): Promise { + io.print(''); + io.print(RULE); + io.print(` ${title} — 바뀔 것`); + for (const line of lines) io.print(` ${line}`); + io.print(RULE); + const yes = await io.confirm('이대로 할까요?'); + if (!yes) io.print('하지 않았습니다.'); + return yes; +} + +/** 임시 비밀번호를 한 번만 보인다 — 어디에도 남기지 않는다 */ +export function showTempPassword( + io: OpsIo, + email: string, + password: string, +): void { + io.print(''); + io.print(RULE); + io.print( + ' 임시 비밀번호 — 지금 한 번만 보입니다. 직원에게 직접(말 · 쪽지) 전하세요.', + ); + io.print(` 주소 ${email}`); + io.print(` 임시 비밀번호 ${password}`); + io.print(' 직원은 첫 로그인 때 새 비밀번호를 정합니다.'); + io.print(RULE); +} + +/** 한 단계의 결과 줄 */ +export function step(io: OpsIo, ok: boolean, text: string): void { + io.print(` ${ok ? '[완료]' : '[못 함]'} ${text}`); +} diff --git a/backend/src/modules/ops/ops-text.ts b/backend/src/modules/ops/ops-text.ts new file mode 100644 index 0000000..ffbe6b8 --- /dev/null +++ b/backend/src/modules/ops/ops-text.ts @@ -0,0 +1,132 @@ +import { BusinessException } from '@/common/errors/business.exception'; +import { formatFileSize } from '@/shared/utils/format'; +import { formatKstDateTime } from '@/shared/utils/kst-date'; +import { OpsEngineError } from './ops-engine.port'; + +/** + * 운영 도구의 글 · 값 읽기 (순수 함수) — 운영자가 넣은 값의 검사와 보이는 글. + * 운영자는 비개발자일 수 있다 — 한국어로, 무엇이 왜 안 되는지와 다음에 할 일을 짧게 + */ + +/** 주소의 로컬 부분 — 소문자 · 숫자 · 점 · 밑줄 · 하이픈, 처음과 끝은 소문자 · 숫자, 64자까지 (엔진 계정 이름) */ +const LOCAL_PART = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; +/** 연속 점은 받지 않는다 (메일 주소 규칙) */ +const DOUBLE_DOT = /\.\./; +/** 이름 · 설명 — 한 줄, 1~100자 */ +export const DISPLAY_NAME_MAX_LENGTH = 100; +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; +/** 메일함 용량 상한 (GB) — 넣을 수 있는 값의 끝 (오타 막기) */ +export const QUOTA_MAX_GB = 10_000; +const BYTES_PER_GB = 1024 ** 3; + +/** + * 운영자가 넣은 주소 → 정식 주소(소문자) — `@도메인` 을 붙이지 않았으면 붙인다. 다른 도메인 · 모양이 틀리면 null + */ +export function toAddress(input: string, domain: string): string | null { + const value = input.trim().toLowerCase(); + if (value === '') return null; + const at = value.indexOf('@'); + const local = at < 0 ? value : value.slice(0, at); + const host = at < 0 ? domain : value.slice(at + 1); + if (host !== domain) return null; + if (!LOCAL_PART.test(local) || DOUBLE_DOT.test(local)) return null; + return `${local}@${domain}`; +} + +/** 표시 이름 · 공용 주소 이름 — 앞뒤 공백을 지운 한 줄 1~100자, 아니면 null */ +export function toDisplayName(input: string): string | null { + const value = input.trim(); + if (value === '' || value.length > DISPLAY_NAME_MAX_LENGTH) return null; + if (CONTROL_CHARACTER.test(value)) return null; + return value; +} + +/** 메일함 용량(GB) — 빈 값은 무제한(null). 양의 수(소수 한 자리까지) · 상한 안이 아니면 'invalid' */ +export function toQuotaBytes(input: string): number | null | 'invalid' { + const value = input.trim(); + if (value === '') return null; + if (!/^\d{1,5}(?:\.\d)?$/.test(value)) return 'invalid'; + const gigabytes = Number(value); + if (!(gigabytes > 0) || gigabytes > QUOTA_MAX_GB) return 'invalid'; + return Math.round(gigabytes * BYTES_PER_GB); +} + +/** 크기 표기 — 없으면 "—" */ +export function bytesText(bytes: number | null): string { + return bytes === null ? '—' : formatFileSize(bytes); +} + +/** 한국 시각 — 없으면 "—" */ +export function timeText(at: Date | null | undefined): string { + return at ? formatKstDateTime(at) : '—'; +} + +/** 응답을 받지 못한 엔진 실패의 까닭 — 엔진이 그 일을 했는지 모른다 */ +const UNKNOWN_OUTCOME_DETAILS: ReadonlySet = new Set([ + 'timeout', + 'network', + 'malformed-response', +]); + +/** + * 엔진이 결과를 알려 주지 않은 실패인가 — 제한 시간 · 연결 끊김 · 응답을 읽지 못함 · 5xx: 엔진이 그 일을 했는지 모른다 (재리뷰 N3). + * 명령은 "결과를 모릅니다 — 같은 명령을 다시 하세요"로 알리고, 했을 수도 있는 일의 뒷정리(재설정의 알림 메일 지우기 등)를 하지 않는다 + */ +export function isUnknownOutcome(error: unknown): boolean { + if (!(error instanceof OpsEngineError) || error.kind !== 'unavailable') { + return false; + } + const detail = error.detail ?? ''; + return UNKNOWN_OUTCOME_DETAILS.has(detail) || /^http-5\d\d$/.test(detail); +} + +/** 실패 → 운영자에게 보일 글 (엔진의 설명 문장 · 비밀은 넣지 않는다 — 종류 · 코드만) */ +export function failureText(error: unknown): string { + if (isUnknownOutcome(error)) { + return `메일 엔진의 답을 받지 못해 결과를 모릅니다 (${(error as OpsEngineError).detail ?? '까닭 모름'}) — 같은 명령을 다시 하세요.`; + } + if (error instanceof OpsEngineError) { + switch (error.kind) { + case 'weak-password': + return '엔진이 임시 비밀번호를 받지 않았습니다(강도). 다시 해 주세요.'; + case 'exists': + return '같은 이름(주소)이 엔진에 이미 있습니다.'; + case 'not-found': + return '엔진에서 대상을 찾지 못했습니다.'; + case 'custom-permissions': + return '이 계정은 권한이 직접 정해져 있어 이 도구로 바꾸지 않습니다 — 엔진 관리 화면에서 하세요.'; + case 'too-many-rules': + return '그 직원의 필터가 이미 50개라 넘기기 규칙을 더할 수 없습니다 — 필터를 줄인 뒤 다시 하세요.'; + case 'not-signed-in': + return '관리자 로그인이 끊겼습니다(비밀번호가 바뀌었을 수 있습니다). 도구를 다시 시작하세요.'; + case 'refused': + return `엔진이 받지 않았습니다 (${error.detail ?? '까닭 모름'}).`; + case 'unavailable': + return `메일 엔진에 닿지 못했습니다 (${error.detail ?? '까닭 모름'}). 잠시 뒤 다시 하세요.`; + } + } + if (error instanceof BusinessException) { + if (error.code === 'MAIL_001') { + return '메일 엔진에 닿지 못했거나 그 직원의 메일함을 열 수 없습니다(잠김 · 비밀번호 만료). 잠시 뒤 다시 하세요.'; + } + if (error.code === 'AUTH_001') { + return '그 직원의 메일함을 열 수 없습니다(계정 없음 · 잠김).'; + } + return `처리하지 못했습니다 (${error.code}).`; + } + // 모르는 오류 — 부르는 쪽(failureLine)이 오류 로그에 원인을 남긴다 + return '처리하지 못했습니다 — 바로 위의 오류 로그(ERROR 줄)를 확인하세요.'; +} + +/** 실행 기록에 남길 실패 요약 — 종류 · 코드만 */ +export function failureDetail(error: unknown): string { + if (error instanceof OpsEngineError) { + return `engine:${error.kind}${error.detail ? `/${error.detail}` : ''}`; + } + if (error instanceof BusinessException) { + const reason: unknown = error.context?.reason; + return `${error.code}${typeof reason === 'string' ? `/${reason}` : ''}`; + } + return error instanceof Error ? error.name : 'unknown'; +} diff --git a/backend/src/modules/ops/ops.module.ts b/backend/src/modules/ops/ops.module.ts new file mode 100644 index 0000000..5f16e2a --- /dev/null +++ b/backend/src/modules/ops/ops.module.ts @@ -0,0 +1,141 @@ +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuthSessionModule } from '@/modules/auth/auth-session.module'; +import { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository'; +import { FileSharingService } from '@/modules/file-sharing/file-sharing.service'; +import { + FILE_SHARING_SETTINGS, + buildFileSharingSettings, +} from '@/modules/file-sharing/file-sharing.settings'; +import { ShareLinkFileEntity } from '@/modules/file-sharing/share-link-file.entity'; +import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity'; +import { FILE_BLOB_STORE } from '@/modules/file-storage/file-blob-store'; +import { + FILE_SCANNER, + FileScanService, +} from '@/modules/file-storage/file-scan.service'; +import { FileStorageRepository } from '@/modules/file-storage/file-storage.repository'; +import { FileStorageService } from '@/modules/file-storage/file-storage.service'; +import { + FILE_STORAGE_SETTINGS, + buildFileStorageSettings, + type FileStorageSettings, +} from '@/modules/file-storage/file-storage.settings'; +import { LocalDiskBlobStore } from '@/modules/file-storage/local-disk-blob-store'; +import { StoredFileChunkEntity } from '@/modules/file-storage/stored-file-chunk.entity'; +import { StoredFileEntity } from '@/modules/file-storage/stored-file.entity'; +import { JmapAccountSecurityProvider } from '@/modules/mail/jmap/jmap-account-security.provider'; +import { JmapFilterScriptStore } from '@/modules/mail/jmap/jmap-filter-script'; +import { JmapOpsEngine } from '@/modules/mail/jmap/jmap-ops-engine'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { buildMailAdminAccount } from '@/modules/mail/mail.settings'; +import { SignatureImageStorage } from '@/modules/mail-signature/signature-image-storage'; +import { SystemMailModule } from '@/modules/system-mail/system-mail.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; +import { buildDatabaseOptions } from '@/shared/config/database.config'; +import { validateEnv } from '@/shared/config/env.validation'; +import { AccountCommands } from './account-commands'; +import { AdminCommands } from './admin-commands'; +import { LockCommands } from './lock-commands'; +import { OpsAuditLogEntity } from './ops-audit-log.entity'; +import { OpsAuditService } from './ops-audit.service'; +import { OpsCli } from './ops-cli'; +import { OpsDataService } from './ops-data.service'; +import { OPS_ADMIN_ACCOUNT, OPS_ENGINE } from './ops-engine.port'; + +/** 운영 도구의 DB 연결 — 한 사람이 차례로 쓰므로 작게 */ +const OPS_DB_POOL = { + max: 4, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, +} as const; + +/** + * 운영 도구 모듈 (13단계 — 계약 6절 "운영 도구") — `node dist/cli/ops.js`(src/cli/ops.ts)가 Nest 앱 컨텍스트로 띄운다. + * **서버와 다른 프로세스**다 — HTTP 서버 · cron(ScheduleModule 없음 — @Interval · @Cron 이 돌지 않는다) · 실시간 엔진 연결 · + * 악성 검사(FILE_SCANNER = null — 검사 차례가 돌지 않는다) · 마이그레이션 실행(migrationsRun false — 서버가 뜰 때 한다) 없이 필요한 것만 둔다. + * 그래서 파일 · 링크 서비스는 그 모듈을 가져오지 않고 여기서 다시 엮는다(FileStorageModule 을 가져오면 이 프로세스에서도 검사 차례가 돈다). + * 도구가 끊은 세션은 DB 에 적히고, 서버가 15초마다 DB 에서 읽어 막는다(revoked-sessions.ts) + */ +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: `.env.${process.env.NODE_ENV || 'development'}`, + validate: validateEnv, + }), + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + ...buildDatabaseOptions(), + // 마이그레이션은 서버가 뜰 때 한다 — 도구는 표를 바꾸지 않는다. SQL 로그는 남기지 않는다(주소가 화면에 섞인다) + migrationsRun: false, + logging: false, + extra: { ...OPS_DB_POOL }, + autoLoadEntities: true, + }), + }), + TypeOrmModule.forFeature([ + OpsAuditLogEntity, + StoredFileEntity, + StoredFileChunkEntity, + ShareLinkEntity, + ShareLinkFileEntity, + ]), + // JmapClient 가 쓰는 캐시 (세션 기억) + CacheModule.register({ isGlobal: true }), + MailEngineModule, + AuthSessionModule, + SystemSettingsModule, + SystemMailModule, + ], + providers: [ + { + provide: OPS_ADMIN_ACCOUNT, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildMailAdminAccount((key) => config.get(key)) ?? '', + }, + JmapAccountSecurityProvider, + JmapFilterScriptStore, + { provide: OPS_ENGINE, useClass: JmapOpsEngine }, + // 파일 · 링크 — 링크 끊기 · 보관 기간 맞추기 · 퇴사 마무리의 파일 지우기 (검사 차례는 돌리지 않는다) + { + provide: FILE_STORAGE_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildFileStorageSettings((key) => config.get(key)), + }, + { + provide: FILE_BLOB_STORE, + inject: [FILE_STORAGE_SETTINGS], + useFactory: (settings: FileStorageSettings) => + new LocalDiskBlobStore(settings.dir), + }, + { provide: FILE_SCANNER, useValue: null }, + FileStorageRepository, + FileScanService, + FileStorageService, + { + provide: FILE_SHARING_SETTINGS, + inject: [ConfigService], + useFactory: (config: ConfigService) => + buildFileSharingSettings((key) => config.get(key)), + }, + FileSharingRepository, + FileSharingService, + { + provide: SignatureImageStorage, + useFactory: () => new SignatureImageStorage(), + }, + OpsDataService, + OpsAuditService, + AccountCommands, + LockCommands, + AdminCommands, + OpsCli, + ], +}) +export class OpsModule {} diff --git a/backend/src/modules/ops/temp-password.ts b/backend/src/modules/ops/temp-password.ts new file mode 100644 index 0000000..2682b32 --- /dev/null +++ b/backend/src/modules/ops/temp-password.ts @@ -0,0 +1,32 @@ +import { randomInt } from 'crypto'; + +/** + * 임시 비밀번호의 글자 — 헷갈리는 글자(0 · O · o · 1 · l · I)를 뺀 영문 대 · 소문자와 숫자. + * 직원이 화면에서 옮겨 적는다. 영문 · 숫자만이라 셸 · 메신저에서 깨지지 않는다 + */ +const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'; + +/** 길이 — 16자(약 93비트). 엔진의 강도 검사(passwordMinStrength)를 넉넉히 넘는다 (2026-11-02 개발 엔진에서 확인) */ +export const TEMP_PASSWORD_LENGTH = 16; + +/** + * 임시 비밀번호 (13단계 — 계약 6절 "운영 도구": 무작위 · 한 번만 보여 준다) — crypto 의 고른 난수. + * 대문자 · 소문자 · 숫자가 하나씩은 들어간다(옮겨 적을 때 섞인 것을 알아보기 쉽게). 저장하지 않고 로그에 남기지 않는다 + */ +export function generateTempPassword( + pick: (max: number) => number = randomInt, +): string { + for (;;) { + let password = ''; + for (let index = 0; index < TEMP_PASSWORD_LENGTH; index += 1) { + password += ALPHABET[pick(ALPHABET.length)]; + } + if ( + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /\d/.test(password) + ) { + return password; + } + } +} diff --git a/backend/src/modules/system-mail/system-mail.module.ts b/backend/src/modules/system-mail/system-mail.module.ts new file mode 100644 index 0000000..54729e9 --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; + +import { JmapSystemMailbox } from '@/modules/mail/jmap/jmap-system-mailbox'; +import { MailEngineModule } from '@/modules/mail/mail-engine.module'; +import { SystemSettingsModule } from '@/modules/system-settings/system-settings.module'; +import { SystemMailService } from './system-mail.service'; +import { SYSTEM_MAILBOX } from './system-mailbox.port'; + +/** + * 받은편지함 알림 메일 모듈 (13단계 — 계약 6절) — 메일 모듈(위험 파일 판정)과 운영 도구(비밀번호 재설정 · 2단계 해제)가 쓴다. + * 엔진 연결은 MailEngineModule(JmapClient), 문의 연락처는 SystemSettingsModule. 메일 모듈을 가져오지 않는다(메일 → 알림 방향만). + * 엔진을 바꿀 때는 SYSTEM_MAILBOX 의 구현(useClass)만 바꾼다 + */ +@Module({ + imports: [MailEngineModule, SystemSettingsModule], + providers: [ + { provide: SYSTEM_MAILBOX, useClass: JmapSystemMailbox }, + SystemMailService, + ], + exports: [SystemMailService], +}) +export class SystemMailModule {} diff --git a/backend/src/modules/system-mail/system-mail.service.spec.ts b/backend/src/modules/system-mail/system-mail.service.spec.ts new file mode 100644 index 0000000..1cbba3b --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.service.spec.ts @@ -0,0 +1,109 @@ +import { Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import type { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { SystemMailService } from './system-mail.service'; +import type { SystemMailMessage } from './system-mailbox.port'; + +const ARA = 'ara.kim@arasystem.test'; + +describe('SystemMailService — 받은편지함 알림 메일 (13단계)', () => { + let mailbox: { + deliver: jest.Mock, [string, SystemMailMessage]>; + remove: jest.Mock; + displayNameOf: jest.Mock; + }; + let settings: { supportContact: jest.Mock }; + let service: SystemMailService; + let warns: string[]; + let infos: string[]; + + beforeEach(() => { + mailbox = { + deliver: jest + .fn, [string, SystemMailMessage]>() + .mockResolvedValue('e-1'), + remove: jest.fn().mockResolvedValue(undefined), + displayNameOf: jest.fn().mockResolvedValue('김아라'), + }; + settings = { + supportContact: jest.fn().mockResolvedValue('IT 담당 홍길동'), + }; + service = new SystemMailService( + mailbox, + settings as unknown as SystemSettingsService, + ); + warns = []; + infos = []; + jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation((m: unknown) => void warns.push(String(m))); + jest + .spyOn(Logger.prototype, 'log') + .mockImplementation((m: unknown) => void infos.push(String(m))); + }); + + afterEach(() => jest.restoreAllMocks()); + + const delivered = (): SystemMailMessage => { + const [, message] = mailbox.deliver.mock.calls[0]; + return message; + }; + + it('비밀번호 재설정 알림을 그 직원의 받은편지함에 넣는다 — 이름은 엔진에서, 문의 연락처는 서버 설정에서', async () => { + await expect(service.notifyPasswordReset(ARA)).resolves.toBe('e-1'); + + expect(mailbox.displayNameOf).toHaveBeenCalledWith(ARA); + expect(mailbox.deliver).toHaveBeenCalledWith(ARA, expect.anything()); + const message = delivered(); + expect(message.kind).toBe('password-reset'); + expect(message.to).toEqual({ email: ARA, name: '김아라' }); + expect(message.subject).toBe('관리자가 비밀번호를 재설정했습니다'); + expect(message.text).toContain('IT 담당 홍길동'); + expect(message.html).toContain('IT 담당 홍길동'); + // 로그에는 가린 주소와 종류만 — 내용은 없다 + expect(infos.join('\n')).toContain('a***@arasystem.test'); + expect(infos.join('\n')).not.toContain('재설정했습니다'); + }); + + it('이름을 알면 엔진에 묻지 않는다 (운영 도구)', async () => { + await service.notifyTwoFactorReset(ARA, { name: '김아라' }); + expect(mailbox.displayNameOf).not.toHaveBeenCalled(); + expect(delivered().kind).toBe('two-factor-reset'); + }); + + it('위험 파일 알림 — 파일 이름 · 크기 · 올린 때가 본문에 든다', async () => { + await service.notifyDangerousFile(ARA, { + name: '견적서.zip', + size: 2048, + uploadedAt: new Date('2026-10-30T06:58:00Z'), + }); + expect(delivered().kind).toBe('dangerous-file'); + expect(delivered().text).toContain('파일: 견적서.zip'); + expect(delivered().text).toContain('크기: 2 KB'); + // 파일 이름은 로그에 남기지 않는다 + expect(infos.join('\n')).not.toContain('견적서'); + }); + + it('넣지 못해도 던지지 않는다 — null 과 warn(가린 주소 · 코드)만', async () => { + mailbox.deliver.mockRejectedValueOnce( + new BusinessException('MAIL_001', { context: { reason: 'timeout' } }), + ); + await expect(service.notifyPasswordReset(ARA)).resolves.toBeNull(); + expect(warns.join('\n')).toContain('[MAIL_001]'); + expect(warns.join('\n')).toContain('MAIL_001/timeout'); + expect(warns.join('\n')).toContain('a***@arasystem.test'); + }); + + it('문의 연락처가 없으면 그 줄 없이 넣는다', async () => { + settings.supportContact.mockResolvedValueOnce(null); + await service.notifyPasswordReset(ARA, { name: '김아라' }); + expect(delivered().html).not.toContain(' { + await expect(service.remove(ARA, 'e-1')).resolves.toBe(true); + mailbox.remove.mockRejectedValueOnce(new BusinessException('MAIL_001')); + await expect(service.remove(ARA, 'e-1')).resolves.toBe(false); + }); +}); diff --git a/backend/src/modules/system-mail/system-mail.service.ts b/backend/src/modules/system-mail/system-mail.service.ts new file mode 100644 index 0000000..b88ea96 --- /dev/null +++ b/backend/src/modules/system-mail/system-mail.service.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { BusinessException } from '@/common/errors/business.exception'; +import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html'; +import { SystemSettingsService } from '@/modules/system-settings/system-settings.service'; +import { maskEmail } from '@/shared/utils/mask'; +import { SYSTEM_MAILBOX, type SystemMailbox } from './system-mailbox.port'; +import { + dangerousFileNotice, + passwordResetNotice, + twoFactorResetNotice, + type DangerousFileInfo, + type NoticeRecipient, + type SystemNotice, +} from './system-notices'; + +function describe(error: unknown): string { + if (error instanceof BusinessException) { + const reason: unknown = error.context?.reason; + return `${error.code}${typeof reason === 'string' ? `/${reason}` : ''}`; + } + return error instanceof Error ? error.message : String(error); +} + +/** + * 받은편지함 알림 메일 (13단계 — 계약 6절 "받은편지함 알림 메일") — 서버가 그 직원의 받은편지함에 넣는다. + * - 보내는 때: 운영 도구의 비밀번호 재설정 · 2단계 해제, 위험 파일 판정(파일마다 처음 기록할 때 한 번 — 메일 모듈이 부른다) + * - **넣지 못해도 원래 일은 성공이다** — 던지지 않고 warn 로그(가린 주소 · 코드)만 남기고 null 을 돌려준다 + * - 본문은 보내는 메일 정화(허용 목록)를 거친다 — 글이 허용 목록 안이라 지워지는 것은 없다(system-notices.spec.ts 가 지킨다) + * - 문의 줄의 연락처는 서버 설정에서 읽는다(읽지 못하면 그 줄 없이) + * 메일 내용 · 파일 이름은 로그에 남기지 않는다 + */ +@Injectable() +export class SystemMailService { + private readonly logger = new Logger(SystemMailService.name); + + constructor( + @Inject(SYSTEM_MAILBOX) private readonly mailbox: SystemMailbox, + private readonly settings: SystemSettingsService, + ) {} + + /** ① 비밀번호 재설정 → 넣은 메일 id (넣지 못하면 null). `name` 을 모르면 엔진에서 읽는다 */ + async notifyPasswordReset( + email: string, + options: { readonly name?: string | null; readonly at?: Date } = {}, + ): Promise { + return this.deliver(email, options.name, (recipient) => + passwordResetNotice(recipient, options.at ?? new Date()), + ); + } + + /** ② 2단계 해제 → 넣은 메일 id (넣지 못하면 null) */ + async notifyTwoFactorReset( + email: string, + options: { readonly name?: string | null; readonly at?: Date } = {}, + ): Promise { + return this.deliver(email, options.name, (recipient) => + twoFactorResetNotice(recipient, options.at ?? new Date()), + ); + } + + /** ③ 위험 파일 판정 → 넣은 메일 id (넣지 못하면 null) */ + async notifyDangerousFile( + email: string, + file: DangerousFileInfo, + ): Promise { + return this.deliver(email, null, (recipient) => + dangerousFileNotice(recipient, file), + ); + } + + /** 넣은 알림을 지운다 (운영 도구 — 엔진 바꾸기가 실패했을 때) → 지웠으면 true. 실패해도 던지지 않는다(warn) */ + async remove(email: string, emailId: string): Promise { + try { + await this.mailbox.remove(email, emailId); + return true; + } catch (error) { + this.logger.warn( + `[MAIL_001] 넣은 알림 메일을 지우지 못했습니다: user=${maskEmail(email)}, reason=${describe(error)}`, + ); + return false; + } + } + + private async deliver( + email: string, + name: string | null | undefined, + build: (recipient: NoticeRecipient) => SystemNotice, + ): Promise { + try { + const recipient: NoticeRecipient = { + name: name?.trim() || (await this.mailbox.displayNameOf(email)), + supportContact: await this.settings.supportContact(), + }; + const notice = build(recipient); + const id = await this.mailbox.deliver(email, { + kind: notice.kind, + to: { email, name: recipient.name }, + subject: notice.subject, + html: sanitizeOutgoingHtml(notice.html), + text: notice.text, + }); + this.logger.log( + `받은편지함 알림 메일을 넣었습니다: user=${maskEmail(email)}, 종류=${notice.kind}`, + ); + return id; + } catch (error) { + this.logger.warn( + `[MAIL_001] 받은편지함 알림 메일을 넣지 못했습니다 — 원래 일은 그대로 끝냅니다: user=${maskEmail(email)}, reason=${describe(error)}`, + ); + return null; + } + } +} diff --git a/backend/src/modules/system-mail/system-mailbox.port.ts b/backend/src/modules/system-mail/system-mailbox.port.ts new file mode 100644 index 0000000..95aaa0f --- /dev/null +++ b/backend/src/modules/system-mail/system-mailbox.port.ts @@ -0,0 +1,30 @@ +import type { SystemNoticeKind } from './system-notices'; + +/** + * 받은편지함 알림 메일을 넣는 엔진 포트 (13단계 — 계약 6절 "받은편지함 알림 메일") — 서비스는 이 인터페이스만 안다. + * 지금 구현은 Stalwart(JMAP) — `modules/mail/jmap/jmap-system-mailbox.ts`(게이트웨이 대리 접근으로 원문을 올리고 Email/import). + * 엔진을 바꿀 때는 system-mail.module.ts 의 useClass 만 바꾼다. 실패는 BusinessException(MAIL_001 등)으로 던진다 + */ +export const SYSTEM_MAILBOX = Symbol('SYSTEM_MAILBOX'); + +/** 넣을 알림 한 통 — 보낸 사람은 어댑터가 정한다(ARA SYSTEM ) */ +export interface SystemMailMessage { + readonly kind: SystemNoticeKind; + /** 받는 직원 — 머리글 To 에 `이름 <주소>` 로 */ + readonly to: { readonly email: string; readonly name: string }; + readonly subject: string; + readonly html: string; + readonly text: string; +} + +export interface SystemMailbox { + /** + * 그 직원의 받은편지함에 넣는다 → 엔진의 메일 id. 안 읽음 · 받은 시각은 지금이다. + * 필터 · 스팸 판정을 거치지 않고 실시간 알림이 간다(엔진 — 2026-10-31 확인) + */ + deliver(user: string, message: SystemMailMessage): Promise; + /** 방금 넣은 알림을 지운다 (운영 도구 — 넣은 뒤 엔진 바꾸기가 실패했을 때) */ + remove(user: string, emailId: string): Promise; + /** 그 직원의 표시 이름 — 엔진에 없거나 읽지 못하면 주소의 @ 앞 */ + displayNameOf(user: string): Promise; +} diff --git a/backend/src/modules/system-mail/system-notices.spec.ts b/backend/src/modules/system-mail/system-notices.spec.ts new file mode 100644 index 0000000..6b068fa --- /dev/null +++ b/backend/src/modules/system-mail/system-notices.spec.ts @@ -0,0 +1,182 @@ +import { sanitizeOutgoingHtml } from '@/modules/mail/html/sanitize-outgoing-html'; +import { + NOTICE_FOOTER, + dangerousFileNotice, + passwordResetNotice, + twoFactorResetNotice, + type SystemNotice, +} from './system-notices'; + +const AT = new Date('2026-10-30T06:20:00Z'); // 2026년 10월 30일 (금) 오후 3:20 KST +const CONTACT = 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it'; +const ARA = { name: '김아라', supportContact: CONTACT }; + +/** style 선언을 견줄 모양으로 — 공백을 빼고 `속성:값` 들의 목록 */ +function styleDeclarations(html: string): string[] { + const declarations: string[] = []; + for (const match of html.matchAll(/style="([^"]*)"/g)) { + for (const part of match[1].split(';')) { + const [property, ...rest] = part.split(':'); + if (!property?.trim()) continue; + declarations.push( + `${property.trim().toLowerCase()}:${rest.join(':').trim().replace(/\s+/g, ' ')}`, + ); + } + } + return declarations.sort(); +} + +/** 태그를 뺀 글 (엔티티는 그대로 — 견주기만 한다) */ +function visibleText(html: string): string { + return html.replace(/<[^>]+>/g, '|'); +} + +const ALL: readonly [string, SystemNotice][] = [ + ['비밀번호 재설정', passwordResetNotice(ARA, AT)], + ['2단계 해제', twoFactorResetNotice(ARA, AT)], + [ + '위험 파일', + dangerousFileNotice(ARA, { + name: '견적_요청서.zip', + size: 50_538_291, + uploadedAt: new Date('2026-10-30T06:58:00Z'), + }), + ], +]; + +describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13단계)', () => { + it('① 비밀번호 재설정 — 제목 · 인사 · 표 · 할 일(메뉴 경로) · 확인 문장', () => { + const notice = passwordResetNotice(ARA, AT); + expect(notice.kind).toBe('password-reset'); + expect(notice.subject).toBe('관리자가 비밀번호를 재설정했습니다'); + expect(notice.text).toBe( + [ + '김아라 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.', + '', + '시각: 2026년 10월 30일 (금) 오후 3:20', + '바뀐 것: 비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다', + '로그아웃: 모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다', + '앱 비밀번호: 모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다', + '', + '할 일', + '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. 웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.', + '', + '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.', + CONTACT, + '', + NOTICE_FOOTER, + '', + ].join('\n'), + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 계정 › 앱 비밀번호에서 새로 만들어 주세요.', + ); + }); + + it('② 2단계 해제 — 그대로인 것을 함께 적는다', () => { + const notice = twoFactorResetNotice(ARA, AT); + expect(notice.subject).toBe('관리자가 2단계 인증을 껐습니다'); + expect(notice.text).toContain( + '김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.', + ); + expect(notice.text).toContain( + '그대로인 것: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저', + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 계정 › 2단계 인증에서 다시 켜 주세요.', + ); + }); + + it('③ 위험 파일 — 파일 이름 그대로 · 11단계 크기 표기 · 올린 때(KST)', () => { + const [, notice] = ALL[2]; + expect(notice.subject).toBe( + '올린 대용량 파일에서 위험한 내용이 발견되었습니다', + ); + expect(notice.text).toContain('파일: 견적_요청서.zip'); + expect(notice.text).toContain('크기: 48 MB'); + expect(notice.text).toContain('올린 때: 2026년 10월 30일 (금) 오후 3:58'); + expect(notice.text).toContain( + '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.', + ); + expect(notice.html).toContain( + '웹메일 › 설정 › 메일 › 대용량 첨부에서 볼 수 있습니다.', + ); + }); + + it.each(ALL)( + '%s — 링크가 없다 (회사 보안 알림에는 링크가 없다)', + (_label, notice) => { + expect(notice.html).not.toMatch(/]/i); + expect(notice.html).not.toMatch(/https?:|mailto:|www\./i); + expect(notice.text).not.toMatch(/https?:|www\./i); + expect(notice.text).toContain(NOTICE_FOOTER); + expect(notice.html).toContain(NOTICE_FOOTER); + }, + ); + + it.each(ALL)( + '%s — 보내는 메일 정화(허용 목록)를 거쳐도 지워지는 태그 · 스타일 · 글이 없다', + (_label, notice) => { + const sanitized = sanitizeOutgoingHtml(notice.html); + expect(styleDeclarations(sanitized)).toEqual( + styleDeclarations(notice.html), + ); + expect(visibleText(sanitized)).toEqual(visibleText(notice.html)); + for (const tag of [ + 'div', + 'p', + 'table', + 'tbody', + 'tr', + 'th', + 'td', + 'strong', + 'br', + ]) { + expect( + (sanitized.match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length, + ).toBe( + (notice.html.match(new RegExp(`<${tag}[\\s>]`, 'g')) ?? []).length, + ); + } + }, + ); + + it('사람이 넣은 글(이름 · 파일 이름 · 연락처)은 HTML 에서 이스케이프한다 — 텍스트는 그대로', () => { + const notice = dangerousFileNotice( + { name: '김&아라', supportContact: '"IT" ' }, + { + name: '.zip', + size: 1024, + uploadedAt: AT, + }, + ); + expect(notice.html).not.toContain(''); + expect(notice.html).not.toContain('김&아라 님,'); + expect(notice.text).toContain('파일: .zip'); + }); + + it('연락처를 정하지 않았으면 그 줄(HTML 은 앞의
와 함께)을 뺀다', () => { + const notice = passwordResetNotice( + { name: '김아라', supportContact: null }, + AT, + ); + expect(notice.html).not.toContain('
'); + expect(notice.html).toContain( + '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.

', + ); + expect(notice.text).toContain( + `직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.\n\n${NOTICE_FOOTER}`, + ); + + const blank = passwordResetNotice( + { name: '김아라', supportContact: ' ' }, + AT, + ); + expect(blank.html).not.toContain('
'); + }); +}); diff --git a/backend/src/modules/system-mail/system-notices.ts b/backend/src/modules/system-mail/system-notices.ts new file mode 100644 index 0000000..9037ca0 --- /dev/null +++ b/backend/src/modules/system-mail/system-notices.ts @@ -0,0 +1,238 @@ +import { formatFileSize } from '@/shared/utils/format'; +import { formatKstDateTime } from '@/shared/utils/kst-date'; + +/** + * 받은편지함 알림 메일의 제목 · 본문 (순수 함수) — 13단계 (계약 6절 "받은편지함 알림 메일"). + * 글은 디자인 README 13단계 "받은편지함 알림 메일"이 원본이다 — 고칠 때는 README 와 함께 고친다. + * + * - 차례: 인사 한 줄 → 표 → 할 일(메뉴 경로 글) → 확인 문장 + 문의 연락처 → 맨 아래 한 줄 + * - **링크를 넣지 않는다** — 할 일은 메뉴 경로를 굵은 글로 적는다 (회사 보안 알림에는 링크가 없다는 습관) + * - HTML 은 보내는 메일 정화(sanitize-outgoing-html.ts)의 허용 목록 안의 태그 · 스타일만 쓴다 — 서비스가 그 정화를 한 번 더 거친다 + * - 사람이 넣은 글(이름 · 파일 이름 · 연락처)은 HTML 에 넣을 때 이스케이프한다. 연락처가 없으면 그 줄(과 앞의
)을 뺀다 + */ + +/** 알림의 종류 — 메일 머리글 X-Ara-Notice 의 값이기도 하다 */ +export type SystemNoticeKind = + | 'password-reset' + | 'two-factor-reset' + | 'dangerous-file'; + +export interface SystemNotice { + readonly kind: SystemNoticeKind; + readonly subject: string; + /** 서식 본문 (정화 허용 목록 안) */ + readonly html: string; + /** 같은 글의 텍스트 본문 */ + readonly text: string; +} + +/** 세 알림이 함께 받는 것 */ +export interface NoticeRecipient { + /** 직원의 표시 이름 — 없으면 주소의 @ 앞을 넣어 부른다 */ + readonly name: string; + /** 문의 연락처 설정값 — 없으면 null (그 줄을 뺀다) */ + readonly supportContact: string | null; +} + +/** 위험 판정을 받은 파일 */ +export interface DangerousFileInfo { + /** 올린 이름 그대로 */ + readonly name: string; + readonly size: number; + /** 올린 때 */ + readonly uploadedAt: Date; +} + +/** 할 일 글의 조각 — 메뉴 경로만 굵게 */ +type TodoPart = { readonly text: string } | { readonly strong: string }; + +interface NoticeBody { + readonly kind: SystemNoticeKind; + readonly subject: string; + readonly greeting: string; + readonly rows: readonly (readonly [label: string, value: string])[]; + readonly todo: readonly TodoPart[]; + readonly confirm: string; +} + +/** 맨 아래 한 줄 (세 통 같음) */ +export const NOTICE_FOOTER = + '이 메일은 ARA SYSTEM 이 자동으로 보낸 알림입니다. 회사 보안 알림에는 링크가 없습니다 — 비슷한 메일의 링크는 누르지 마세요.'; + +/** 확인 문장 — 비밀번호 재설정 · 2단계 해제 */ +const CONFIRM_SELF = '직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.'; + +// ── HTML 꼴 (디자인 README 13단계 "HTML 꼴" 그대로) ───────────────────────────── +const STYLE_WRAPPER = + "font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75; color: #1F2733"; +const STYLE_GREETING = 'margin: 0 0 14px'; +const STYLE_TABLE = + 'width: 100%; border-collapse: collapse; margin: 0 0 18px; font-size: 13.5px; line-height: 1.6'; +const STYLE_TH = + 'width: 104px; padding: 8px 12px; border: 1px solid #E6E8EC; vertical-align: top; background-color: #F8F9FB; color: #4A5363; font-weight: 600; text-align: left'; +const STYLE_TD = + 'padding: 8px 12px; border: 1px solid #E6E8EC; vertical-align: top'; +const STYLE_TODO_TITLE = 'margin: 0 0 2px; font-weight: 700'; +const STYLE_TODO = 'margin: 0 0 20px'; +const STYLE_CONFIRM = + 'margin: 0; padding-top: 14px; border-top: 1px solid #EEF0F3; font-size: 13px; color: #4A5363'; +const STYLE_FOOTER = 'margin: 6px 0 0; font-size: 12px; color: #667085'; + +const TODO_TITLE = '할 일'; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function todoHtml(parts: readonly TodoPart[]): string { + return parts + .map((part) => + 'strong' in part + ? `${escapeHtml(part.strong)}` + : escapeHtml(part.text), + ) + .join(''); +} + +function todoText(parts: readonly TodoPart[]): string { + return parts + .map((part) => ('strong' in part ? part.strong : part.text)) + .join(''); +} + +/** 본문을 HTML · 텍스트 두 꼴로 */ +function render(body: NoticeBody, recipient: NoticeRecipient): SystemNotice { + const contact = recipient.supportContact?.trim() || null; + const rowsHtml = body.rows + .map( + ([label, value]) => + `${escapeHtml(label)}${escapeHtml(value)}`, + ) + .join(''); + const html = + `
` + + `

${escapeHtml(body.greeting)}

` + + `${rowsHtml}
` + + `

${TODO_TITLE}

` + + `

${todoHtml(body.todo)}

` + + `

${escapeHtml(body.confirm)}${contact ? `
${escapeHtml(contact)}` : ''}

` + + `

${escapeHtml(NOTICE_FOOTER)}

` + + `
`; + + const text = [ + body.greeting, + '', + ...body.rows.map(([label, value]) => `${label}: ${value}`), + '', + TODO_TITLE, + todoText(body.todo), + '', + body.confirm, + ...(contact ? [contact] : []), + '', + NOTICE_FOOTER, + '', + ].join('\n'); + + return { kind: body.kind, subject: body.subject, html, text }; +} + +/** ① 관리자가 비밀번호를 재설정했습니다 — 운영 도구의 「비밀번호 재설정」 뒤 */ +export function passwordResetNotice( + recipient: NoticeRecipient, + at: Date, +): SystemNotice { + return render( + { + kind: 'password-reset', + subject: '관리자가 비밀번호를 재설정했습니다', + greeting: `${recipient.name} 님, 관리자가 이 계정의 비밀번호를 임시 비밀번호로 바꿨습니다.`, + rows: [ + ['시각', formatKstDateTime(at)], + [ + '바뀐 것', + '비밀번호 — 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정합니다', + ], + ['로그아웃', '모든 기기 · 브라우저의 웹메일 로그인을 끊었습니다'], + [ + '앱 비밀번호', + '모두 지웠습니다 — 메일 프로그램은 새 앱 비밀번호로 다시 연결합니다', + ], + ], + todo: [ + { + text: '아웃룩 · 휴대폰 메일 앱을 쓰면 메일 프로그램마다 앱 비밀번호가 필요합니다. ', + }, + { strong: '웹메일 › 설정 › 계정 › 앱 비밀번호' }, + { text: '에서 새로 만들어 주세요.' }, + ], + confirm: CONFIRM_SELF, + }, + recipient, + ); +} + +/** ② 관리자가 2단계 인증을 껐습니다 — 운영 도구의 「2단계 해제」(휴대폰 분실) 뒤 */ +export function twoFactorResetNotice( + recipient: NoticeRecipient, + at: Date, +): SystemNotice { + return render( + { + kind: 'two-factor-reset', + subject: '관리자가 2단계 인증을 껐습니다', + greeting: `${recipient.name} 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 이 계정의 2단계 인증을 껐습니다.`, + rows: [ + ['시각', formatKstDateTime(at)], + ['바뀐 것', '2단계 인증 꺼짐 — 지금은 비밀번호만으로 로그인합니다'], + ['그대로인 것', '비밀번호 · 앱 비밀번호 · 로그인된 브라우저'], + ], + todo: [ + { text: '새 휴대폰에 인증 앱을 설치한 뒤 ' }, + { strong: '웹메일 › 설정 › 계정 › 2단계 인증' }, + { + text: '에서 다시 켜 주세요. 회사가 2단계 인증을 필수로 정했다면 다음 로그인 때 켜는 화면이 먼저 나옵니다.', + }, + ], + confirm: CONFIRM_SELF, + }, + recipient, + ); +} + +/** ③ 올린 대용량 파일에서 위험한 내용이 발견되었습니다 — 악성 검사가 위험으로 판정했을 때(파일마다 한 통) */ +export function dangerousFileNotice( + recipient: NoticeRecipient, + file: DangerousFileInfo, +): SystemNotice { + return render( + { + kind: 'dangerous-file', + subject: '올린 대용량 파일에서 위험한 내용이 발견되었습니다', + greeting: `${recipient.name} 님, 올린 대용량 파일을 보안 검사했더니 위험한 내용이 발견되어 보내거나 내려받을 수 없게 막았습니다.`, + rows: [ + ['파일', file.name], + ['크기', formatFileSize(file.size)], + ['올린 때', formatKstDateTime(file.uploadedAt)], + [ + '조치', + '이 파일만 막았습니다 — 함께 올린 다른 파일은 그대로 보내고 받을 수 있습니다', + ], + ], + todo: [ + { + text: '직접 만든 파일이 아니면 PC 를 백신으로 검사해 주세요. 다른 곳에서 받은 파일이면 보낸 사람에게도 알려 주세요. 막은 파일은 ', + }, + { strong: '웹메일 › 설정 › 메일 › 대용량 첨부' }, + { text: '에서 볼 수 있습니다.' }, + ], + confirm: '판정이 잘못되었다고 생각되면 IT 담당에게 알려 주세요.', + }, + recipient, + ); +} diff --git a/backend/src/modules/system-settings/dto/support-contact.dto.ts b/backend/src/modules/system-settings/dto/support-contact.dto.ts new file mode 100644 index 0000000..4f0265e --- /dev/null +++ b/backend/src/modules/system-settings/dto/support-contact.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +import { SUPPORT_CONTACT_MAX_LENGTH } from '../system-settings.entity'; + +/** GET /api/support-contact (계약 6절 "문의 연락처") — 로그인 없이 받는다 */ +export class SupportContactDto { + @ApiProperty({ + description: `문의 연락처 — ${SUPPORT_CONTACT_MAX_LENGTH}자 이하 · 한 줄, 글자로만 보인다(링크 · HTML 없음). 운영 도구로 정한다. 정하지 않았으면 null`, + type: String, + nullable: true, + maxLength: SUPPORT_CONTACT_MAX_LENGTH, + example: 'IT 담당 홍길동 · 02-000-0000 · 사내 메신저 @it', + }) + text!: string | null; +} diff --git a/backend/src/modules/system-settings/support-contact.controller.ts b/backend/src/modules/system-settings/support-contact.controller.ts new file mode 100644 index 0000000..adb4cbe --- /dev/null +++ b/backend/src/modules/system-settings/support-contact.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ApiStandardResponse } from '@/common/decorators/api-standard-response.decorator'; +import { SupportContactDto } from './dto/support-contact.dto'; +import { SystemSettingsService } from './system-settings.service'; + +/** + * 문의 연락처 (계약 6절 "문의 연락처" — 13단계) — 로그인 없이 받는다(로그인 화면 · 코드 단계 · 2단계 켜기가 보인다). + * 가드가 없어 제한된 세션에서도 그대로 부른다. 요청 수는 전역 기본 한도 — 서버는 설정을 10초 기억해 DB 를 매번 묻지 않는다. + * 읽지 못하면 null 로 답한다(화면은 지금 글 그대로) + */ +@ApiTags('Support') +@Controller('support-contact') +export class SupportContactController { + constructor(private readonly settings: SystemSettingsService) {} + + @Get() + @ApiOperation({ + summary: '문의 연락처 (로그인 없음)', + description: + '운영 도구로 정한 문의 연락처 — 200자 이하 · 한 줄. 정하지 않았거나 읽지 못하면 text 가 null 이다(화면은 지금 글 그대로).', + }) + @ApiStandardResponse(SupportContactDto, { description: '문의 연락처' }) + async get(): Promise { + return { text: await this.settings.supportContact() }; + } +} diff --git a/backend/src/modules/system-settings/system-settings.entity.ts b/backend/src/modules/system-settings/system-settings.entity.ts new file mode 100644 index 0000000..6e5d036 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.entity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; + +/** 문의 연락처 길이 (계약 6절 "문의 연락처" — 200자 이하 · 한 줄). PostgreSQL varchar 는 코드 포인트로 센다 — 넣기 전에 서비스가 JS 길이로 본다 */ +export const SUPPORT_CONTACT_MAX_LENGTH = 200; + +/** 서버 설정 줄의 id — 표에는 이 한 줄만 있다 (마이그레이션 AddCutoverPrep 이 넣는다) */ +export const SYSTEM_SETTINGS_ROW_ID = 1; + +/** + * 서버 설정 (13단계 — 계약 6절) — 표에 한 줄(id 1)만 둔다. 운영 도구(dist/cli/ops.js)가 바꾸고 서버가 읽는다. + * - 2단계 인증 필수 정책: 켜짐 · 시작일(한국 날짜 — 그날 0시 KST 부터) · 예외 주소(소문자) + * - 문의 연락처: 로그인 화면 · 코드 단계 · 받은편지함 알림 메일의 마지막 줄 (null — 정하지 않음) + * 처음 값은 꺼짐 · 시작일 없음 · 예외 없음 · 연락처 없음이다 + */ +@Entity({ name: 'system_settings' }) +export class SystemSettingsEntity { + @PrimaryColumn({ type: 'smallint' }) + id!: number; + + @Column({ name: 'two_factor_required', type: 'boolean' }) + twoFactorRequired!: boolean; + + /** 'YYYY-MM-DD' (한국 날짜) — 켜짐인데 null 이면 바로 시작한 것으로 본다 */ + @Column({ name: 'two_factor_start_date', type: 'date', nullable: true }) + twoFactorStartDate!: string | null; + + /** 정책에서 빼는 직원 주소 (소문자) */ + @Column({ name: 'two_factor_exempt', type: 'jsonb' }) + twoFactorExempt!: string[]; + + @Column({ + name: 'support_contact', + type: 'varchar', + length: SUPPORT_CONTACT_MAX_LENGTH, + nullable: true, + }) + supportContact!: string | null; + + @Column({ name: 'updated_at', type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/system-settings/system-settings.module.ts b/backend/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000..eb63a87 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { SupportContactController } from './support-contact.controller'; +import { SystemSettingsEntity } from './system-settings.entity'; +import { SystemSettingsService } from './system-settings.service'; + +/** + * 서버 설정 모듈 (13단계 — 계약 6절) — 2단계 인증 필수 정책 · 문의 연락처(표 system_settings 한 줄). + * 계정 보안 모듈(정책 판정) · 받은편지함 알림 메일(문의 줄) · 운영 도구(바꾸기)가 서비스를 쓴다. 이 모듈은 다른 기능 모듈을 가져오지 않는다 + */ +@Module({ + imports: [TypeOrmModule.forFeature([SystemSettingsEntity])], + controllers: [SupportContactController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/backend/src/modules/system-settings/system-settings.service.ts b/backend/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000..c15e21c --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,133 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +import { + SYSTEM_SETTINGS_ROW_ID, + SystemSettingsEntity, +} from './system-settings.entity'; +import { + DEFAULT_SYSTEM_SETTINGS, + type SystemSettings, + type TwoFactorPolicy, +} from './system-settings.types'; + +/** + * 설정을 기억하는 시간 — 로그인 · 갱신 · me · 문의 연락처마다 DB 를 묻지 않게. + * 운영 도구는 다른 프로세스라 이 서버의 기억을 지우지 못한다 — 바꾼 값은 이 시간 안에 보인다 + */ +export const SYSTEM_SETTINGS_CACHE_TTL_MS = 10_000; + +/** 바꿀 칸 — 보낸 칸만 바꾼다 (운영 도구) */ +export interface SystemSettingsPatch { + readonly twoFactor?: Partial; + readonly supportContact?: string | null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** 표의 줄 → 설정 — 손으로 고친 이상한 값은 그 칸만 처음 값으로 읽는다 */ +function toSettings(row: SystemSettingsEntity | null): SystemSettings { + if (row === null) return DEFAULT_SYSTEM_SETTINGS; + const exempt = Array.isArray(row.twoFactorExempt) + ? row.twoFactorExempt + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim().toLowerCase()) + : []; + return { + twoFactor: { + required: row.twoFactorRequired === true, + startDate: + typeof row.twoFactorStartDate === 'string' + ? row.twoFactorStartDate + : null, + exempt, + }, + supportContact: + typeof row.supportContact === 'string' && row.supportContact !== '' + ? row.supportContact + : null, + }; +} + +/** + * 서버 설정 (13단계 — 계약 6절) — 2단계 인증 필수 정책과 문의 연락처. 표(system_settings)에 한 줄이다. + * - 읽기는 SYSTEM_SETTINGS_CACHE_TTL_MS 동안 기억한다. 읽지 못하면 그 실패를 던진다 — 다만 기억한 값이 있으면(지났어도) 그 값을 준다 + * - 바꾸기는 운영 도구만 한다(API 없음) — 보낸 칸만, 이 프로세스의 기억은 바로 맞춘다 + * 줄이 없으면(마이그레이션 전 · 손으로 지움) 처음 값으로 읽고, 바꿀 때 만든다 + */ +@Injectable() +export class SystemSettingsService { + private readonly logger = new Logger(SystemSettingsService.name); + private cached: { value: SystemSettings; expiresAt: number } | null = null; + + constructor( + @InjectRepository(SystemSettingsEntity) + private readonly rows: Repository, + ) {} + + async get(): Promise { + const now = Date.now(); + if (this.cached && this.cached.expiresAt > now) return this.cached.value; + try { + const value = toSettings( + await this.rows.findOneBy({ id: SYSTEM_SETTINGS_ROW_ID }), + ); + this.cached = { value, expiresAt: now + SYSTEM_SETTINGS_CACHE_TTL_MS }; + return value; + } catch (error) { + if (this.cached) { + this.logger.warn( + `[SYS_001] 서버 설정을 읽지 못해 기억한 값을 씁니다: ${errorMessage(error)}`, + ); + return this.cached.value; + } + throw error; + } + } + + /** 문의 연락처 (계약 6절 — 로그인 없이 받는다). 읽지 못하면 null(화면은 지금 글 그대로) — error 로그 */ + async supportContact(): Promise { + try { + return (await this.get()).supportContact; + } catch (error) { + this.logger.error( + `[SYS_001] 문의 연락처를 읽지 못해 없는 것으로 답합니다: ${errorMessage(error)}`, + ); + return null; + } + } + + /** 기억을 건너뛰고 다시 읽는다 (운영 도구가 바꾸기 전 · 뒤에 보인다) */ + async reload(): Promise { + this.cached = null; + return this.get(); + } + + /** 보낸 칸만 바꾸고 바꾼 값을 돌려준다 (운영 도구) — 값의 검사는 부르는 쪽이 한다 */ + async update(patch: SystemSettingsPatch, at: Date): Promise { + const current = await this.reload(); + const twoFactor: TwoFactorPolicy = { + ...current.twoFactor, + ...patch.twoFactor, + }; + const supportContact = + patch.supportContact === undefined + ? current.supportContact + : patch.supportContact; + await this.rows.upsert( + { + id: SYSTEM_SETTINGS_ROW_ID, + twoFactorRequired: twoFactor.required, + twoFactorStartDate: twoFactor.startDate, + twoFactorExempt: [...new Set(twoFactor.exempt)], + supportContact, + updatedAt: at, + }, + ['id'], + ); + return this.reload(); + } +} diff --git a/backend/src/modules/system-settings/system-settings.spec.ts b/backend/src/modules/system-settings/system-settings.spec.ts new file mode 100644 index 0000000..1fb0875 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.spec.ts @@ -0,0 +1,234 @@ +import { Logger } from '@nestjs/common'; +import type { Repository } from 'typeorm'; + +import { SupportContactController } from './support-contact.controller'; +import type { SystemSettingsEntity } from './system-settings.entity'; +import { + SYSTEM_SETTINGS_CACHE_TTL_MS, + SystemSettingsService, +} from './system-settings.service'; +import { + checkSupportContact, + sessionTwoFactorState, + twoFactorRequirementFor, + type TwoFactorPolicy, +} from './system-settings.types'; + +const ARA = 'ara.kim@arasystem.test'; +const START = new Date('2026-11-30T15:00:00.000Z'); // 2026-12-01 0시 KST + +const policy = (overrides: Partial = {}): TwoFactorPolicy => ({ + required: true, + startDate: '2026-12-01', + exempt: [], + ...overrides, +}); + +describe('2단계 인증 필수 정책 판정 (13단계 — 계약 6절)', () => { + it('꺼져 있으면 아무에게도 걸리지 않는다', () => { + const requirement = twoFactorRequirementFor( + policy({ required: false }), + ARA, + new Date(), + ); + expect(requirement).toEqual({ + isRequired: false, + startsAt: START, + isStarted: false, + }); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('시작일 전(유예) — 꺼진 직원은 시작 시각(twoFactorDeadline), 제한 없음', () => { + const requirement = twoFactorRequirementFor( + policy(), + ARA, + new Date(START.getTime() - 1), + ); + expect(requirement).toEqual({ + isRequired: true, + startsAt: START, + isStarted: false, + }); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: null, + deadline: START, + }); + // 이미 켠 직원은 안내도 없다 + expect(sessionTwoFactorState(requirement, true)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('시작일 0시(KST)부터 — 꺼진 직원은 twoFactorSetup 제한, 켠 직원은 그대로', () => { + const requirement = twoFactorRequirementFor(policy(), ARA, START); + expect(requirement.isStarted).toBe(true); + expect(sessionTwoFactorState(requirement, false)).toEqual({ + restriction: 'twoFactorSetup', + deadline: null, + }); + expect(sessionTwoFactorState(requirement, true)).toEqual({ + restriction: null, + deadline: null, + }); + }); + + it('예외 주소는 걸리지 않는다 — 대소문자 · 앞뒤 공백을 무시한다', () => { + const requirement = twoFactorRequirementFor( + policy({ exempt: [ARA] }), + ' Ara.Kim@AraSystem.test ', + START, + ); + expect(requirement.isRequired).toBe(false); + expect(requirement.isStarted).toBe(false); + }); + + it('시작일이 없으면(또는 달력에 없는 값이면) 켠 때부터다', () => { + for (const startDate of [null, '2026-02-30']) { + const requirement = twoFactorRequirementFor( + policy({ startDate }), + ARA, + new Date('2020-01-01T00:00:00Z'), + ); + expect(requirement).toEqual({ + isRequired: true, + startsAt: null, + isStarted: true, + }); + } + }); +}); + +describe('문의 연락처 검사 — 200자 · 한 줄', () => { + it('앞뒤 공백을 지우고, 빈 글은 null(정하지 않음)', () => { + expect(checkSupportContact(' IT 담당 홍길동 ')).toEqual({ + ok: true, + value: 'IT 담당 홍길동', + }); + expect(checkSupportContact(' ')).toEqual({ ok: true, value: null }); + }); + + it('줄바꿈 · 탭 · 제어 문자는 받지 않는다', () => { + for (const text of ['a\nb', 'a\rb', 'a\tb', 'a\u0000b', 'a\u2028b']) { + expect(checkSupportContact(text)).toEqual({ + ok: false, + problem: 'not-single-line', + }); + } + }); + + it('200자(JS 문자열 길이)까지', () => { + expect(checkSupportContact('가'.repeat(200)).ok).toBe(true); + expect(checkSupportContact('가'.repeat(201))).toEqual({ + ok: false, + problem: 'too-long', + }); + }); +}); + +describe('SystemSettingsService — 서버 설정 한 줄', () => { + const NOW = Date.parse('2026-11-02T03:00:00Z'); + let row: Partial | null; + let rows: { findOneBy: jest.Mock; upsert: jest.Mock }; + let service: SystemSettingsService; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + row = { + id: 1, + twoFactorRequired: true, + twoFactorStartDate: '2026-12-01', + twoFactorExempt: ['Boss@AraSystem.test'], + supportContact: 'IT 담당', + updatedAt: new Date(NOW), + }; + rows = { + findOneBy: jest.fn(() => Promise.resolve(row)), + upsert: jest.fn((next: Partial) => { + row = { ...row, ...next }; + return Promise.resolve(); + }), + }; + service = new SystemSettingsService( + rows as unknown as Repository, + ); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('표의 줄을 읽어 기억한다 — 예외 주소는 소문자로, 10초 안에는 다시 묻지 않는다', async () => { + await expect(service.get()).resolves.toEqual({ + twoFactor: { + required: true, + startDate: '2026-12-01', + exempt: ['boss@arasystem.test'], + }, + supportContact: 'IT 담당', + }); + await service.get(); + expect(rows.findOneBy).toHaveBeenCalledTimes(1); + + jest + .spyOn(Date, 'now') + .mockReturnValue(NOW + SYSTEM_SETTINGS_CACHE_TTL_MS + 1); + await service.get(); + expect(rows.findOneBy).toHaveBeenCalledTimes(2); + }); + + it('줄이 없으면 처음 값 — 꺼짐 · 연락처 없음', async () => { + row = null; + await expect(service.get()).resolves.toEqual({ + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, + }); + }); + + it('읽지 못하면 기억한 값(지났어도)을 쓰고, 기억이 없으면 던진다 — 문의 연락처는 null 로 답한다', async () => { + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.get()).rejects.toThrow('db down'); + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.supportContact()).resolves.toBeNull(); + + await service.get(); + jest + .spyOn(Date, 'now') + .mockReturnValue(NOW + SYSTEM_SETTINGS_CACHE_TTL_MS + 1); + rows.findOneBy.mockRejectedValueOnce(new Error('db down')); + await expect(service.supportContact()).resolves.toBe('IT 담당'); + }); + + it('바꾸기는 보낸 칸만 — 다른 칸은 지금 값 그대로, 예외 주소는 겹치지 않게', async () => { + const next = await service.update( + { + twoFactor: { exempt: ['a@arasystem.test', 'a@arasystem.test'] }, + }, + new Date(NOW), + ); + expect(rows.upsert).toHaveBeenCalledWith( + { + id: 1, + twoFactorRequired: true, + twoFactorStartDate: '2026-12-01', + twoFactorExempt: ['a@arasystem.test'], + supportContact: 'IT 담당', + updatedAt: new Date(NOW), + }, + ['id'], + ); + expect(next.twoFactor.exempt).toEqual(['a@arasystem.test']); + + await service.update({ supportContact: null }, new Date(NOW)); + expect((await service.get()).supportContact).toBeNull(); + }); + + it('GET /api/support-contact — { text }', async () => { + const controller = new SupportContactController(service); + await expect(controller.get()).resolves.toEqual({ text: 'IT 담당' }); + }); +}); diff --git a/backend/src/modules/system-settings/system-settings.types.ts b/backend/src/modules/system-settings/system-settings.types.ts new file mode 100644 index 0000000..f4aec37 --- /dev/null +++ b/backend/src/modules/system-settings/system-settings.types.ts @@ -0,0 +1,103 @@ +import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator'; +import { kstStartOfDate } from '@/shared/utils/kst-date'; +import { SUPPORT_CONTACT_MAX_LENGTH } from './system-settings.entity'; + +/** + * 2단계 인증 필수 정책 (계약 6절 "2단계 인증 필수") — 운영 도구로 바꾼다. 엔진에는 강제하는 설정이 없어 서버 정책으로 둔다. + * 처음 값은 꺼짐이다 + */ +export interface TwoFactorPolicy { + readonly required: boolean; + /** 시작일 'YYYY-MM-DD'(한국 날짜 — 그날 0시 KST 부터 막는다) — null 이면 켠 때부터 */ + readonly startDate: string | null; + /** 정책에서 빼는 직원 주소 (소문자) */ + readonly exempt: readonly string[]; +} + +/** 서버 설정 (system_settings 한 줄) */ +export interface SystemSettings { + readonly twoFactor: TwoFactorPolicy; + /** 문의 연락처 (200자 이하 · 한 줄) — 정하지 않았으면 null */ + readonly supportContact: string | null; +} + +export const DEFAULT_SYSTEM_SETTINGS: SystemSettings = { + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, +}; + +/** 이 직원에게 정책이 걸리는가 */ +export interface TwoFactorRequirement { + /** 정책이 켜져 있고 이 직원이 예외가 아니다 — 시작일 전(유예)에도 true (필수인 직원은 끄지 못한다) */ + readonly isRequired: boolean; + /** 시작 시각(시작일 0시 KST) — 시작일이 없으면 null(켠 때부터) */ + readonly startsAt: Date | null; + /** 시작했는가 — 필수이고 지금이 시작 시각 이후 */ + readonly isStarted: boolean; +} + +/** 세션에 붙일 2단계 인증 상태 — 제한 또는 유예 중의 시작 시각 */ +export interface SessionTwoFactorState { + readonly restriction: Extract | null; + /** 유예 중이면 시작 시각 (twoFactorDeadline) — 아니면 null */ + readonly deadline: Date | null; +} + +/** 이 직원에게 정책이 걸리는가 (순수 함수) — 주소는 소문자로 견준다. 시작일이 달력에 없는 값이면 시작일 없음(켠 때부터)으로 읽는다 */ +export function twoFactorRequirementFor( + policy: TwoFactorPolicy, + email: string, + now: Date, +): TwoFactorRequirement { + const address = email.trim().toLowerCase(); + const isRequired = policy.required && !policy.exempt.includes(address); + const startsAt = + policy.startDate === null ? null : kstStartOfDate(policy.startDate); + return { + isRequired, + startsAt, + isStarted: + isRequired && (startsAt === null || now.getTime() >= startsAt.getTime()), + }; +} + +/** + * 세션의 2단계 인증 상태 (순수 함수 — 계약 6절 "2단계 인증 필수"): + * - 필수 · 시작함 · 꺼짐 → 제한 twoFactorSetup + * - 필수 · 유예 중 · 꺼짐 → 시작 시각(twoFactorDeadline — 화면이 메일함 위에 안내 줄) + * - 그 밖(필수 아님 · 이미 켬) → 둘 다 없음 + */ +export function sessionTwoFactorState( + requirement: TwoFactorRequirement, + isEnabled: boolean, +): SessionTwoFactorState { + if (!requirement.isRequired || isEnabled) { + return { restriction: null, deadline: null }; + } + if (requirement.isStarted) { + return { restriction: 'twoFactorSetup', deadline: null }; + } + return { restriction: null, deadline: requirement.startsAt }; +} + +/** 문의 연락처 검사 결과 — 고친 값(빈 글은 null — 지우기) 또는 문제 */ +export type SupportContactCheck = + | { readonly ok: true; readonly value: string | null } + | { readonly ok: false; readonly problem: 'too-long' | 'not-single-line' }; + +/** 줄바꿈 · 탭을 포함한 제어 문자 (한 줄 · 글자로만 보인다) */ +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** 문의 연락처 (계약 6절 — 200자 이하 · 한 줄, 길이는 JS 문자열 길이) — 앞뒤 공백을 지운다. 빈 글이면 null(정하지 않음) */ +export function checkSupportContact(input: string): SupportContactCheck { + const value = input.trim(); + if (value === '') return { ok: true, value: null }; + if (CONTROL_CHARACTER.test(value)) { + return { ok: false, problem: 'not-single-line' }; + } + if (value.length > SUPPORT_CONTACT_MAX_LENGTH) { + return { ok: false, problem: 'too-long' }; + } + return { ok: true, value }; +} diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 1604d48..4bda5f3 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -16,6 +16,7 @@ import { import { ApiErrorResponse, + ApiSessionErrors, ApiStandardResponse, } from '@/common/decorators/api-standard-response.decorator'; import { BusinessException } from '@/common/errors/business.exception'; @@ -29,7 +30,7 @@ import { UploadService } from './upload.service'; @ApiTags('Upload') @ApiCookieAuth() @UseGuards(JwtAuthGuard) -@ApiErrorResponse('AUTH_001', '로그인 필요 (쿠키 없음·만료)') +@ApiSessionErrors('로그인 필요 (쿠키 없음·만료)') @Controller('files') export class UploadController { constructor(private readonly uploadService: UploadService) {} diff --git a/backend/src/shared/utils/kst-date.spec.ts b/backend/src/shared/utils/kst-date.spec.ts index 2038431..c362734 100644 --- a/backend/src/shared/utils/kst-date.spec.ts +++ b/backend/src/shared/utils/kst-date.spec.ts @@ -1,4 +1,7 @@ import { + formatKstDateTime, + kstDateString, + kstStartOfDate, formatKstDate, formatKstDateEnglish, kstEndOfDayAfter, @@ -46,3 +49,53 @@ describe('formatKstDate · formatKstDateEnglish', () => { expect(formatKstDateEnglish(at)).toBe('Nov 30, 2026'); }); }); + +describe('kstStartOfDate · kstDateString — 2단계 인증 필수의 시작일 (13단계)', () => { + it('한국 날짜의 0시를 UTC 로 — 계약 6절의 예', () => { + expect(kstStartOfDate('2026-12-01')?.toISOString()).toBe( + '2026-11-30T15:00:00.000Z', + ); + expect(kstStartOfDate('2027-01-01')?.toISOString()).toBe( + '2026-12-31T15:00:00.000Z', + ); + }); + + it('달력에 없는 날짜 · 모양이 틀린 값은 null', () => { + for (const value of [ + '2026-02-30', + '2026-13-01', + '2026-1-01', + '20261201', + '', + '2026-12-01T00:00:00Z', + ]) { + expect(kstStartOfDate(value)).toBeNull(); + } + }); + + it('한국 날짜를 YYYY-MM-DD 로 — UTC 15시부터 다음 날', () => { + expect(kstDateString(new Date('2026-11-30T14:59:59.999Z'))).toBe( + '2026-11-30', + ); + expect(kstDateString(new Date('2026-11-30T15:00:00.000Z'))).toBe( + '2026-12-01', + ); + }); +}); + +describe('formatKstDateTime — 알림 메일의 시각 (디자인 README 13단계)', () => { + it('"2026년 10월 30일 (금) 오후 3:20" 꼴', () => { + expect(formatKstDateTime(new Date('2026-10-30T06:20:00Z'))).toBe( + '2026년 10월 30일 (금) 오후 3:20', + ); + }); + + it('0시는 오전 12시, 12시는 오후 12시 — 분은 두 자리', () => { + expect(formatKstDateTime(new Date('2026-10-26T15:05:00Z'))).toBe( + '2026년 10월 27일 (화) 오전 12:05', + ); + expect(formatKstDateTime(new Date('2026-10-27T03:00:00Z'))).toBe( + '2026년 10월 27일 (화) 오후 12:00', + ); + }); +}); diff --git a/backend/src/shared/utils/kst-date.ts b/backend/src/shared/utils/kst-date.ts index 1f146a7..fbd889a 100644 --- a/backend/src/shared/utils/kst-date.ts +++ b/backend/src/shared/utils/kst-date.ts @@ -61,3 +61,50 @@ export function formatKstDateEnglish(at: Date): string { const { year, month, day } = kstParts(at); return `${MONTHS_EN[month - 1]} ${day}, ${year}`; } + +/** 'YYYY-MM-DD' — 달력에 있는 날짜만 받는다 */ +const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; + +/** + * 한국 날짜('YYYY-MM-DD')의 0시(한국 시간)를 UTC 로 — 달력에 없는 날짜(2026-02-30)나 모양이 틀리면 null. + * 예) '2026-12-01' → `2026-11-30T15:00:00.000Z` (13단계 — 2단계 인증 필수의 시작일, 계약 6절의 예) + */ +export function kstStartOfDate(date: string): Date | null { + const match = DATE_ONLY.exec(date); + if (!match) return null; + const [year, month, day] = [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + ]; + const utc = Date.UTC(year, month - 1, day); + const check = new Date(utc); + if ( + check.getUTCFullYear() !== year || + check.getUTCMonth() !== month - 1 || + check.getUTCDate() !== day + ) { + return null; + } + return new Date(utc - KST_OFFSET_MS); +} + +/** `at` 의 한국 날짜를 'YYYY-MM-DD' 로 */ +export function kstDateString(at: Date): string { + const { year, month, day } = kstParts(at); + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** + * "2026년 10월 30일 (금) 오후 3:20" — 한국 시각 (13단계 받은편지함 알림 메일의 `{시각}` · `{올린 때}` — 디자인 README 13단계). + * 12시간제 — 0시는 "오전 12:00", 12시는 "오후 12:00", 분은 두 자리 + */ +export function formatKstDateTime(at: Date): string { + const shifted = new Date(at.getTime() + KST_OFFSET_MS); + const { year, month, day, weekday } = kstParts(at); + const hours = shifted.getUTCHours(); + const minutes = String(shifted.getUTCMinutes()).padStart(2, '0'); + const period = hours < 12 ? '오전' : '오후'; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + return `${year}년 ${month}월 ${day}일 (${WEEKDAYS_KO[weekday]}) ${period} ${hour12}:${minutes}`; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 59d52f6..8d9bbdb 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -73,7 +73,11 @@ import { type MailImageResponse, type ResolvedAddress, } from '@/modules/mail/images/mail-image-fetcher'; -import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; +import { + AccountLoginDisabledException, + MAIL_PROVIDER, + PasswordExpiredException, +} from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { BlockedSender, @@ -117,10 +121,21 @@ import { SIGNATURE_IMAGE_MAX_BYTES, } from '@/modules/mail-signature/mail-signature.constants'; import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository'; +import { + SystemSettingsService, + type SystemSettingsPatch, +} from '@/modules/system-settings/system-settings.service'; import { UPLOAD_DIR } from '@/modules/upload/upload.config'; import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; -import { totpCode, totpCounterAt, verifyTotp } from '@/shared/utils/totp'; +import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date'; +import { + buildOtpauthUrl, + encodeBase32, + totpCode, + totpCounterAt, + verifyTotp, +} from '@/shared/utils/totp'; import { pngBytes } from './image-fixtures'; /** @@ -172,6 +187,8 @@ function cookieValue(cookie: string | undefined): string { } const EMPLOYEE = { email: 'ara.kim@e2e.arasystem.test', name: '김아라' }; +/** 13단계 — 로그인(signedIn) · me · 갱신 응답은 세션의 제한 · 2단계 필수 유예를 늘 싣는다 (제한 없음 · 유예 아님) */ +const UNRESTRICTED = { restriction: null, twoFactorDeadline: null }; /** 대역 메일 엔진이 받아 주는 비밀번호 (그 밖의 비밀번호는 틀린 것으로 본다) */ const CORRECT_PASSWORD = 'correct-password-for-e2e'; @@ -344,6 +361,11 @@ const GATE_DEADLINE_MS = 10_000; * test/jest-e2e.json 에 testTimeout 이 없어 jest 기본값(5초)이라, 이 테스트만 따로 늘린다. */ const GATED_TEST_TIMEOUT_MS = 20_000; +/** + * 앱을 띄우는 준비(맨 바깥 beforeAll)의 시간 제한 — DB 연결 · 마이그레이션, 그리고 TypeORM 이 그때 읽는 엔티티 · 마이그레이션 파일의 ts-jest 변환이 들어 있다. + * 변환 캐시가 빈 실행을 바인드 마운트 너머(개발 컨테이너)에서 돌리면 jest 기본값(5초)을 넘어 모든 테스트가 실패했다 (2026-11-02, 13단계) + */ +const APP_SETUP_TIMEOUT_MS = 60_000; /** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */ function createGate(): { readonly opened: Promise; open: () => void } { @@ -642,6 +664,10 @@ describe('응답 계약 (e2e)', () => { /** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */ const twoFactorUrls = new Map(); + /** 13단계 — 대역 엔진에서 비밀번호가 만료된 직원 (메일 기능이 막히고, 본인이 바꾸면 엔진이 만료를 푼다) */ + const expiredPasswords = new Set(); + /** 13단계 — 대역 엔진에서 잠긴 직원 (로그인 권한을 뺐다 — 대리 접근 403, 게이트웨이는 정상) */ + const lockedAccounts = new Set(); const appPasswordsByUser = new Map(); let appPasswordSequence = 0; /** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */ @@ -906,12 +932,22 @@ describe('응답 계약 (e2e)', () => { }) .overrideProvider(MAIL_PROVIDER) .useValue({ - // 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다 + // 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다. + // 13단계 — 비밀번호가 만료된 계정은 어댑터가 PasswordExpiredException 으로 알린다(메일 메서드가 forbidden) getProfile: (email: string) => - Promise.resolve({ - email, - name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, - }), + lockedAccounts.has(email) + ? Promise.reject(new AccountLoginDisabledException(email)) + : expiredPasswords.has(email) + ? Promise.reject( + new PasswordExpiredException( + email, + PROFILE_NAMES.get(email) ?? EMPLOYEE.name, + ), + ) + : Promise.resolve({ + email, + name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name, + }), verifyCredentials: async ( email: string, password: string, @@ -1147,7 +1183,10 @@ describe('응답 계약 (e2e)', () => { [ENGINE_REFUSED_PASSWORD, 'current-password-refused'], [ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'], ]); - return Promise.resolve(outcomes.get(newPassword) ?? 'changed'); + const outcome = outcomes.get(newPassword) ?? 'changed'; + // 13단계 — 엔진은 본인이 비밀번호를 바꾸면 만료를 스스로 푼다 + if (outcome === 'changed') expiredPasswords.delete(email); + return Promise.resolve(outcome); }, createMailbox: ( user: string, @@ -1536,7 +1575,7 @@ describe('응답 계약 (e2e)', () => { server = app.getHttpServer(); jwt = app.get(JwtService, { strict: false }); authSettings = app.get(AUTH_SETTINGS, { strict: false }); - }); + }, APP_SETUP_TIMEOUT_MS); afterAll(async () => { await dataSource.query( @@ -1852,7 +1891,10 @@ describe('응답 계약 (e2e)', () => { const res = await refreshWith(refreshToken).expect(200); - expect(body(res)).toEqual({ success: true, data: EMPLOYEE }); + expect(body(res)).toEqual({ + success: true, + data: { ...EMPLOYEE, ...UNRESTRICTED }, + }); // 토큰은 본문에 없다 expect(JSON.stringify(body(res))).not.toContain('eyJ'); @@ -1872,7 +1914,7 @@ describe('응답 계약 (e2e)', () => { .get('/api/auth/me') .set('Cookie', `access_token=${cookieValue(access)}`) .expect(200); - expect(body(me).data).toEqual(EMPLOYEE); + expect(body(me).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED }); }); it('만료된 액세스 쿠키가 함께 와도 /auth/refresh 는 리프레시 쿠키로 동작한다', async () => { @@ -1917,7 +1959,7 @@ describe('응답 계약 (e2e)', () => { const second = rotated(await refreshWith(first).expect(200)); const late = await refreshWith(first).expect(200); - expect(body(late).data).toEqual(EMPLOYEE); + expect(body(late).data).toEqual({ ...EMPLOYEE, ...UNRESTRICTED }); expect(late.headers['set-cookie']).toBeUndefined(); // 이긴 쪽의 새 토큰은 그대로 쓸 수 있다 @@ -6830,6 +6872,8 @@ describe('응답 계약 (e2e)', () => { expect(body(before).data).toEqual({ isEnabled: false, enabledAt: null, + isRequired: false, + deadline: null, }); const enrolled = await asMe( @@ -6875,6 +6919,8 @@ describe('응답 계약 (e2e)', () => { expect(body(activated).data).toEqual({ isEnabled: true, enabledAt: expect.any(String) as unknown, + isRequired: false, + deadline: null, }); expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl); @@ -6884,6 +6930,7 @@ describe('응답 계약 (e2e)', () => { status: 'twoFactorRequired', email: null, name: null, + ...UNRESTRICTED, }); expect(first.headers['set-cookie']).toBeUndefined(); @@ -6902,6 +6949,7 @@ describe('응답 계약 (e2e)', () => { status: 'signedIn', email: ME, name: EMPLOYEE.name, + ...UNRESTRICTED, }); expect(cookieValue(setCookie(second, 'access_token'))).not.toBe(''); @@ -6928,7 +6976,12 @@ describe('응답 계약 (e2e)', () => { otpCode: otpCodeOf(secret, -1), }) .expect(200); - expect(body(off).data).toEqual({ isEnabled: false, enabledAt: null }); + expect(body(off).data).toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: false, + deadline: null, + }); expect(twoFactorUrls.has(ME)).toBe(false); // 다시 비밀번호만으로 로그인한다 @@ -7473,6 +7526,367 @@ describe('응답 계약 (e2e)', () => { }); }); + describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => { + let systemSettings: SystemSettingsService; + /** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */ + let original: SystemSettingsPatch | undefined; + /** 처음 값 — 시험마다 이것에서 시작한다 */ + const DEFAULTS: SystemSettingsPatch = { + twoFactor: { required: false, startDate: null, exempt: [] }, + supportContact: null, + }; + + const loginAs = (email: string, from: string) => + request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', from) + .send({ email, password: CORRECT_PASSWORD }); + const cookiesOf = (res: Response) => [ + `access_token=${cookieValue(setCookie(res, 'access_token'))}`, + `refresh_token=${cookieValue(setCookie(res, 'refresh_token'))}`, + ]; + const settle = (patch: SystemSettingsPatch) => + systemSettings.update(patch, new Date()); + + beforeAll(async () => { + systemSettings = app.get(SystemSettingsService, { strict: false }); + const current = await systemSettings.reload(); + original = { + twoFactor: { ...current.twoFactor }, + supportContact: current.supportContact, + }; + }); + beforeEach(() => settle(DEFAULTS)); + afterAll(async () => { + if (original) await settle(original); + }); + + it('비밀번호가 만료된 직원 — 쿠키는 받되 passwordChange, 허용한 경로만 부르고 그 밖은 403 AUTH_010 → 바꾸면 풀린다', async () => { + const ME = 'expired.me@e2e.arasystem.test'; + expiredPasswords.add(ME); + try { + const res = await loginAs(ME, '198.18.13.1').expect(200); + expect(body(res).data).toEqual({ + status: 'signedIn', + email: ME, + name: EMPLOYEE.name, + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + const cookies = cookiesOf(res); + const as = (test: HttpTest) => test.set('Cookie', cookies); + + const me = await as(request(server).get('/api/auth/me')).expect(200); + expect(body(me).data).toEqual({ + ...EMPLOYEE, + email: ME, + restriction: 'passwordChange', + twoFactorDeadline: null, + }); + await as(request(server).get('/api/auth/sessions')).expect(200); + await as(request(server).get('/api/two-factor')).expect(200); + await as(request(server).get('/api/support-contact')).expect(200); + for (const path of [ + '/api/mailboxes', + '/api/mail-state', + '/api/mail-events', + '/api/contacts', + '/api/app-passwords', + ]) { + const blocked = await as(request(server).get(path)).expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + } + // 갱신도 엔진에 다시 묻는다 — 아직 만료면 그대로 + const still = await as( + request(server).post('/api/auth/refresh'), + ).expect(200); + expect(body(still).data).toMatchObject({ + restriction: 'passwordChange', + }); + + // 새 비밀번호 — 엔진이 만료를 풀고 서버가 세션의 제한을 지운다 + const fresh = cookiesOf(still); + await request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', '198.18.13.1') + .set('Cookie', fresh) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-for-e2e-13', + }) + .expect(200); + // 제한을 푼 뒤에는 옛 액세스 토큰(rst 클레임)으로도 세션 표의 지금 값 — 마친 단계가 다시 보이지 않는다 + const after = await request(server) + .get('/api/auth/me') + .set('Cookie', fresh) + .expect(200); + expect(body(after).data).toMatchObject(UNRESTRICTED); + await request(server) + .get('/api/mailboxes') + .set('Cookie', fresh) + .expect(200); + const renewed = await request(server) + .post('/api/auth/refresh') + .set('Cookie', fresh) + .expect(200); + expect(body(renewed).data).toEqual({ + ...EMPLOYEE, + email: ME, + ...UNRESTRICTED, + }); + } finally { + expiredPasswords.delete(ME); + } + }); + + it('2단계 인증 필수 — 시작일부터 켜지 않은 직원은 twoFactorSetup · 끄기는 400 VAL_001, 유예 중에는 시작 시각만, 예외 주소는 그대로', async () => { + const ME = 'policy.me@e2e.arasystem.test'; + const today = kstDateString(new Date()); + await settle({ twoFactor: { required: true, startDate: today } }); + + const res = await loginAs(ME, '198.18.13.2').expect(200); + expect(body(res).data).toMatchObject({ + restriction: 'twoFactorSetup', + twoFactorDeadline: null, + }); + const cookies = cookiesOf(res); + const status = await request(server) + .get('/api/two-factor') + .set('Cookie', cookies) + .expect(200); + expect(body(status).data).toEqual({ + isEnabled: false, + enabledAt: null, + isRequired: true, + deadline: null, + }); + const blocked = await request(server) + .get('/api/mailboxes') + .set('Cookie', cookies) + .expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + + // 유예 — 시작일이 내일이면 제한 없이 시작 시각(내일 0시 KST)만 + const tomorrow = kstDateString(new Date(Date.now() + 24 * 3_600_000)); + await settle({ twoFactor: { startDate: tomorrow } }); + const grace = await loginAs(ME, '198.18.13.3').expect(200); + expect(body(grace).data).toMatchObject({ + restriction: null, + twoFactorDeadline: kstStartOfDate(tomorrow)?.toISOString(), + }); + const graceStatus = await request(server) + .get('/api/two-factor') + .set('Cookie', cookiesOf(grace)) + .expect(200); + expect(body(graceStatus).data).toMatchObject({ + isRequired: true, + deadline: kstStartOfDate(tomorrow)?.toISOString(), + }); + + // 예외 주소는 정책을 따르지 않는다 + await settle({ twoFactor: { startDate: today, exempt: [ME] } }); + const exempt = await loginAs(ME, '198.18.13.4').expect(200); + expect(body(exempt).data).toMatchObject(UNRESTRICTED); + }); + + it('만료 계정 + 2단계 필수 시작 — 비밀번호를 바꾸면 그 세션은 twoFactorSetup 으로 바뀌어 옛 토큰으로도 다음 단계 밖은 막힌다 (리뷰 M2)', async () => { + const ME = 'expired.policy@e2e.arasystem.test'; + expiredPasswords.add(ME); + try { + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + const res = await loginAs(ME, '198.18.13.6').expect(200); + // 비밀번호 만료가 먼저다 + expect(body(res).data).toMatchObject({ restriction: 'passwordChange' }); + const cookies = cookiesOf(res); + + await request(server) + .put('/api/auth/password') + .set('X-Forwarded-For', '198.18.13.6') + .set('Cookie', cookies) + .send({ + currentPassword: CORRECT_PASSWORD, + newPassword: 'new-password-for-e2e-13m2', + }) + .expect(200); + + // 옛 토큰(rst=passwordChange) — 가드는 세션 표의 지금 값(twoFactorSetup)을 본다 + const blocked = await request(server) + .get('/api/mailboxes') + .set('Cookie', cookies) + .expect(403); + expect(body(blocked).error?.code).toBe('AUTH_010'); + const me = await request(server) + .get('/api/auth/me') + .set('Cookie', cookies) + .expect(200); + expect(body(me).data).toMatchObject({ restriction: 'twoFactorSetup' }); + // 켜기 단계의 경로는 연다 + await request(server) + .get('/api/two-factor') + .set('Cookie', cookies) + .expect(200); + } finally { + expiredPasswords.delete(ME); + } + }); + + it('2단계를 켜면 요청 세션의 제한만 풀리고, twoFactorSetup 이 붙은 다른 세션은 끊긴다 (리뷰 M1)', async () => { + const ME = 'policy.two@e2e.arasystem.test'; + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + // 같은 직원의 두 세션 — 둘 다 2단계를 거치지 않았다(비밀번호만 아는 다른 사람의 세션일 수 있다) + const mine = cookiesOf(await loginAs(ME, '198.18.13.7').expect(200)); + const other = cookiesOf(await loginAs(ME, '198.18.13.8').expect(200)); + try { + const enrolled = await request(server) + .post('/api/two-factor/enrollments') + .set('Cookie', mine) + .expect(201); + const { enrollmentId, secret } = dataOf<{ + enrollmentId: string; + secret: string; + }>(enrolled); + const activated = await request(server) + .post('/api/two-factor/activation') + .set('Cookie', mine) + .send({ + enrollmentId, + otpCode: otpCodeOf(decodeBase32(secret)), + currentPassword: CORRECT_PASSWORD, + }) + .expect(200); + expect(body(activated).data).toMatchObject({ + isEnabled: true, + isRequired: true, + deadline: null, + }); + + // 켠 세션 — 옛 토큰으로도 제한이 풀렸다 + const me = await request(server) + .get('/api/auth/me') + .set('Cookie', mine) + .expect(200); + expect(body(me).data).toMatchObject(UNRESTRICTED); + await request(server) + .get('/api/mailboxes') + .set('Cookie', mine) + .expect(200); + + // 다른 세션 — 2단계 없이 열리지 않고 끊겼다 + const cut = await request(server) + .get('/api/mailboxes') + .set('Cookie', other) + .expect(401); + expect(body(cut).error?.code).toBe('AUTH_001'); + await request(server) + .post('/api/auth/refresh') + .set('Cookie', other) + .expect(401); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it('잠긴 계정(2단계 켜짐 — 엔진이 2단계를 먼저 봐 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 (실사용 시험)', async () => { + const ME = 'locked.otp@e2e.arasystem.test'; + const secret = randomBytes(20); + twoFactorUrls.set( + ME, + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: ME, + secret: encodeBase32(secret), + }), + ); + lockedAccounts.add(ME); + try { + const right = await loginAs(ME, '198.18.13.9').expect(401); + const withCode = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.10') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret), + }) + .expect(401); + const wrong = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.11') + .send({ email: ME, password: 'wrong-password-for-e2e' }) + .expect(401); + // 셋 다 같은 코드 · 문구 — 맞는 비밀번호인지 바깥에서 가를 수 없다 + for (const res of [right, withCode, wrong]) { + expect(body(res).error?.code).toBe('AUTH_004'); + expect(body(res).error?.message).toBe(body(wrong).error?.message); + expect(res.headers['set-cookie']).toBeUndefined(); + } + } finally { + lockedAccounts.delete(ME); + twoFactorUrls.delete(ME); + } + }); + + it('필수인 직원은 2단계 인증을 끌 수 없다 — 400 VAL_001 (엔진에 묻지 않는다)', async () => { + const ME = 'policy.on@e2e.arasystem.test'; + // 켠 직원으로 만든다 — 대역 엔진에 바로 넣고 비밀번호 + 코드로 로그인 + const secret = randomBytes(20); + twoFactorUrls.set( + ME, + buildOtpauthUrl({ + issuer: 'ARA SYSTEM', + account: ME, + secret: encodeBase32(secret), + }), + ); + try { + await settle({ + twoFactor: { required: true, startDate: kstDateString(new Date()) }, + }); + const res = await request(server) + .post('/api/auth/login') + .set('X-Forwarded-For', '198.18.13.5') + .send({ + email: ME, + password: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret), + }) + .expect(200); + // 켠 직원은 제한 · 유예가 없다 + expect(body(res).data).toMatchObject({ + status: 'signedIn', + ...UNRESTRICTED, + }); + const off = await request(server) + .post('/api/two-factor/deactivation') + .set('Cookie', cookiesOf(res)) + .send({ + currentPassword: CORRECT_PASSWORD, + otpCode: otpCodeOf(secret, 1), + }) + .expect(400); + expect(body(off).error?.code).toBe('VAL_001'); + expect(twoFactorUrls.has(ME)).toBe(true); + } finally { + twoFactorUrls.delete(ME); + } + }); + + it('문의 연락처 — 로그인 없이 { text }, 정하지 않았으면 null', async () => { + const none = await request(server) + .get('/api/support-contact') + .expect(200); + expect(body(none).data).toEqual({ text: null }); + + await settle({ supportContact: 'IT 담당 홍길동 · 02-000-0000' }); + const set = await request(server).get('/api/support-contact').expect(200); + expect(body(set).data).toEqual({ text: 'IT 담당 홍길동 · 02-000-0000' }); + }); + }); + describe('출처 검사 (상태 변경 요청)', () => { const EVIL_ORIGIN = 'https://evil.example'; diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 4927838..d8894c4 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -12,6 +12,7 @@ const Map kErrorCodeMessages = { 'AUTH_007': '인증 앱의 코드를 입력해 주세요.', 'AUTH_008': '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', 'AUTH_009': '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', + 'AUTH_010': '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', 'VAL_001': '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', 'RES_001': '요청하신 정보나 페이지를 찾을 수 없습니다.', 'BIZ_001': '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.', diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 06352d8..fd92520 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -12,6 +12,7 @@ export const ERROR_CODE_MESSAGES = { AUTH_007: '인증 앱의 코드를 입력해 주세요.', AUTH_008: '2단계 인증 설정이 그사이 바뀌었거나 시간이 지났습니다. 처음부터 다시 해 주세요.', AUTH_009: '5개까지 만들 수 있습니다. 쓰지 않는 앱 비밀번호를 지운 뒤 만들어 주세요.', + AUTH_010: '먼저 비밀번호를 바꾸거나 2단계 인증을 켜 주세요.', VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)', RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.', BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',