generated from ttipo/project-base
feat(backend): 12단계 계정 보안 — 2단계 로그인 · 2FA · 앱 비밀번호 · 세션 끊기 · 로그인 기록 · 엔진 웹훅
엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다. - 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20) - 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만 - 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록 - 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다 - 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다 - 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다 에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 계정 보안 표 (docs/mail/api-contract.md 5절 "계정 보안" — 12단계).
|
||||
* - login_events: 로그인 기록(웹메일 · 메일 프로그램) — 90일 보존. 직원 · 일어난 때 · 차례 번호 인덱스(커서 페이지),
|
||||
* 일어난 때 인덱스(정리), 엔진 이벤트의 겹침 거르기 키 유일 인덱스(웹메일 기록은 null — 여럿이어도 된다)
|
||||
* - app_password_usages: 앱 비밀번호의 서버 기록(이름 · 엔진의 만든 때 · 마지막 사용) — 직원 · 앱 비밀번호 id 마다 한 줄
|
||||
* - two_factor_settings: 웹메일에서 2단계 인증을 켠 때 — 직원 주소마다 한 줄 (비밀은 두지 않는다)
|
||||
* - auth_sessions: 로그인 · 갱신한 클라이언트의 IP · User-Agent 칸(12단계 전의 세션은 null)과 직원 주소 인덱스(로그인된 브라우저 목록 · 다른 곳 모두 끊기)
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하고 표를 새로 만들기만 한다.
|
||||
* (자동 생성: npm run migration:generate — 엔티티는 modules/account-security/*.entity.ts · modules/auth/auth-session.entity.ts)
|
||||
*/
|
||||
export class AddAccountSecurity1793415011070 implements MigrationInterface {
|
||||
name = 'AddAccountSecurity1793415011070';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "login_events" ("id" BIGSERIAL NOT NULL, "user_email" character varying(254) NOT NULL, "occurred_at" TIMESTAMP WITH TIME ZONE NOT NULL, "method" character varying(32) NOT NULL, "is_success" boolean NOT NULL, "is_two_factor" boolean NOT NULL, "failure_reason" character varying(32), "ip" character varying(64), "user_agent" character varying(512), "app_password_id" character varying(16), "app_password_name" character varying(40), "dedupe_key" character(43), CONSTRAINT "PK_5cd6102474db62bc181590b9902" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_occurred_at" ON "login_events" ("occurred_at") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_login_events_dedupe_key" ON "login_events" ("dedupe_key") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_login_events_user_occurred" ON "login_events" ("user_email", "occurred_at", "id") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "app_password_usages" ("user_email" character varying(254) NOT NULL, "app_password_id" character varying(16) NOT NULL, "name" character varying(40) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, "last_used_at" TIMESTAMP WITH TIME ZONE, "last_used_method" character varying(8), "last_used_ip" character varying(64), CONSTRAINT "PK_6db68c01bf629755c5eeaf95322" PRIMARY KEY ("user_email", "app_password_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "two_factor_settings" ("user_email" character varying(254) NOT NULL, "enabled_at" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_e1f907880f3d367cca2717934b6" PRIMARY KEY ("user_email"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "ip" character varying(64)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "user_agent" character varying(512)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_auth_sessions_user_email" ON "auth_sessions" ("user_email") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_auth_sessions_user_email"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" DROP COLUMN "user_agent"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "ip"`);
|
||||
await queryRunner.query(`DROP TABLE "two_factor_settings"`);
|
||||
await queryRunner.query(`DROP TABLE "app_password_usages"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_user_occurred"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "public"."UQ_login_events_dedupe_key"`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_login_events_occurred_at"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "login_events"`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user