feat(backend): 13단계 전환 준비 — 제한된 세션 · 2단계 필수 · 운영 도구 · 받은편지함 알림 · 문의 연락처

전 직원 전환 때 관리자가 계정을 다루고(입사 · 비밀번호 재설정 · 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 15:37:03 +09:00
parent 73babeaa2c
commit 452d9fef1d
107 changed files with 10903 additions and 192 deletions
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* 전환 준비 표 (docs/mail/api-contract.md 6절 "전환 준비 — 13단계").
* - auth_sessions.restriction: 제한된 세션 — passwordChange(비밀번호 바꾸기 전) · twoFactorSetup(2단계 인증 켜기 전) · null. 기존 세션은 null(제한 없음)
* - system_settings: 서버 설정 한 줄(id 1) — 2단계 인증 필수 정책(꺼짐 · 시작일 · 예외 주소)과 문의 연락처. 처음 값은 꺼짐 · 연락처 없음이다
* - ops_audit_log: 운영 도구의 실행 기록 — 언제 · 누가 · 명령 · 대상 · 결과(비밀은 남기지 않는다). 일어난 때 인덱스
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
* (엔티티: modules/auth/auth-session.entity.ts · modules/system-settings/system-settings.entity.ts · modules/ops/ops-audit-log.entity.ts —
* 만든 뒤 migration:generate 가 "변경 없음"인 것을 확인했다)
*/
export class AddCutoverPrep1793585725784 implements MigrationInterface {
name = 'AddCutoverPrep1793585725784';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "auth_sessions" ADD "restriction" character varying(32)`,
);
await queryRunner.query(
`CREATE TABLE "system_settings" ("id" smallint NOT NULL, "two_factor_required" boolean NOT NULL, "two_factor_start_date" date, "two_factor_exempt" jsonb NOT NULL, "support_contact" character varying(200), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_82521f08790d248b2a80cc85d40" PRIMARY KEY ("id"))`,
);
// 한 줄만 둔다 — 처음 값: 2단계 인증 필수 꺼짐 · 예외 없음 · 문의 연락처 없음
await queryRunner.query(
`INSERT INTO "system_settings" ("id", "two_factor_required", "two_factor_start_date", "two_factor_exempt", "support_contact", "updated_at") VALUES (1, false, NULL, '[]', NULL, now())`,
);
await queryRunner.query(
`CREATE TABLE "ops_audit_log" ("id" BIGSERIAL NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "operator" character varying(100) NOT NULL, "command" character varying(32) NOT NULL, "target" character varying(254), "result" character varying(16) NOT NULL, "detail" character varying(500), CONSTRAINT "PK_79f2db0d5b46ba1d43348bbc85c" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ops_audit_log_occurred_at" ON "ops_audit_log" ("occurred_at") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_ops_audit_log_occurred_at"`,
);
await queryRunner.query(`DROP TABLE "ops_audit_log"`);
await queryRunner.query(`DROP TABLE "system_settings"`);
await queryRunner.query(
`ALTER TABLE "auth_sessions" DROP COLUMN "restriction"`,
);
}
}