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
@@ -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,
@@ -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) {}
@@ -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) */
@@ -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) {}
@@ -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<TwoFactorSettingEntity>,
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);
});
});
@@ -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<TwoFactorSettingEntity>,
@Inject(ACCOUNT_SECURITY_PROVIDER)
private readonly provider: AccountSecurityProvider,
) {}
/** 이 직원에게 정책이 걸리는가 (GET /api/two-factor 의 isRequired · deadline, 끄기 막기) */
async requirementFor(
email: string,
now: Date = new Date(),
): Promise<TwoFactorRequirement> {
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<SessionTwoFactorState> {
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<boolean> {
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<boolean> {
if (await this.records.existsBy({ userEmail: email })) return true;
return this.provider.isTwoFactorEnabled(email);
}
}
@@ -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<TwoFactorStatusDto> {
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<TwoFactorStatusDto> {
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<TwoFactorStatusDto> {
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));
}
}
@@ -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<Promise<boolean>, [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,
});
});
});
});
@@ -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<TwoFactorStatusView> {
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<TwoFactorStatus> {
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<void> {
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<TwoFactorStatus> {
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');
@@ -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;
}
@@ -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<boolean> {
const result = await this.sessions.update(
{ id, restriction: from },
{ restriction: to },
);
return (result.affected ?? 0) > 0;
}
/** 세션 하나의 제한을 지운다 — 지금 그 제한일 때만 → 지웠으면 true */
clearRestriction(
id: string,
restriction: SessionRestriction,
): Promise<boolean> {
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<number> {
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<boolean> {
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,
@@ -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 {
@@ -31,6 +31,7 @@ const session = (
revokedReason: null,
ip: '211.186.95.252',
userAgent: CHROME,
restriction: null,
...extra,
});
@@ -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;
@@ -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);
});
});
+41 -8
View File
@@ -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<AuthUserDto> {
// 제한은 세션 표의 지금 값, 유예는 정책으로 다시 본 값 (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)',
+428 -1
View File
@@ -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('세션의 제한을 바꾸지 못했습니다');
});
});
});
+270 -23
View File
@@ -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<MailAccountProfile> {
client: ClientInfo,
): Promise<LoadedProfile> {
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<void> {
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<SessionState> {
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<AuthResult> {
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<MailAccountProfile> {
): Promise<LoadedProfile> {
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<MailAccountProfile> {
client: ClientInfo,
): Promise<LoadedProfile> {
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<SessionTokens> {
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<SessionTokens> {
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<LoadedProfile> {
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<SessionState> {
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) },
@@ -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;
}
@@ -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;
}
+121 -27
View File
@@ -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<string, Partial<AuthSessionEntity>>();
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<unknown>,
): Promise<string | undefined> {
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');
});
});
+36 -1
View File
@@ -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<AuthenticatedUser> {
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<AuthenticatedUser['restriction']> {
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;
}
}
@@ -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);
});
});
});
+77 -14
View File
@@ -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<string, number>();
/** 마지막으로 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<void> {
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<number> {
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<number> {
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);
@@ -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 {
@@ -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) {}
@@ -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 {
@@ -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(
@@ -127,6 +127,27 @@ export class FileSharingRepository {
return (result.affected ?? 0) > 0;
}
/** 그 직원의 살아 있는 링크(끊지 않았고 · 취소하지 않았고 · 기간 안) id — 운영 도구의 잠금 (13단계) */
async aliveIdsOf(owner: string, now: Date): Promise<string[]> {
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<ShareLinkRecord | null> {
const link = await this.links.findOneBy({ id });
if (!link) return null;
const [record] = await this.withFiles([link]);
return record;
}
/**
* 받은 수를 하나 더한다 — 상한 아래일 때만 (한 문장이라 동시에 받아도 상한을 넘지 않는다).
* @returns 더했으면 true, 상한에 닿았으면 false
@@ -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<ShareLinkRecord | null> {
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<boolean> {
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<number> {
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) ─────────────────────────
/** 주소마다 상태 · 끝나는 날만 — 누구의 링크든 같다. 요청 차례 그대로 */
@@ -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) {}
@@ -90,6 +90,16 @@ class FakeRepository {
return Promise.resolve(true);
}
/** 실제 저장소와 같은 조건 — ready 이고 아직 위험으로 적히지 않았을 때만 */
markInfected(id: string, changes: Partial<StoredFileEntity>) {
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<StoredFileEntity>) {
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'));
@@ -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 {
@@ -182,6 +182,26 @@ export class FileStorageRepository {
return (result.affected ?? 0) > 0;
}
/**
* 위험 판정을 적는다 — 아직 위험으로 적히지 않았고 올리기를 끝낸(ready) 파일일 때만 (조건부 UPDATE 한 번).
* 같은 파일의 검사가 겹쳐도(인스턴스 둘 · 다시 검사) 처음 적은 쪽만 true — 받은편지함 알림 메일은 파일마다 한 번이다 (13단계)
* @returns 적었으면 true
*/
async markInfected(
id: string,
changes: Partial<StoredFileEntity>,
): Promise<boolean> {
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
@@ -110,6 +110,10 @@ export interface FileScanVerdictEvent {
readonly fileId: string;
readonly name: string;
readonly scanStatus: ScanStatus;
/** 파일 크기 (바이트) — 받은편지함 알림 메일의 「크기」 (13단계) */
readonly size: number;
/** 올린 때(다 올린 때 — 없으면 만들기 시작한 때) — 받은편지함 알림 메일의 「올린 때」 (13단계) */
readonly uploadedAt: Date;
}
/** 내려받을 파일 내용 — 범위가 있으면 그 부분만 */
@@ -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) {}
@@ -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) {}
@@ -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) {}
+2 -2
View File
@@ -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)
@@ -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) {}
@@ -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)', () => {
@@ -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<JmapGetResult<JmapIdentity>>(
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<JmapEmailSubmission>
>(responses, `${HELD_CALLS}g`, 'EmailSubmission/get');
@@ -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, unknown>, 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<string, string>)
.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<string, unknown>
>;
expect(create.a).toMatchObject({
'@type': 'User',
name: 'hong',
domainId: 'd1',
description: '홍길동',
roles: { '@type': 'User' },
quotas: { maxDiskQuota: 5 },
});
const credential = (
create.a.credentials as Record<string, Record<string, unknown>>
)['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<string, unknown>
>
).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<string, unknown>).acc1,
).toEqual({ 'credentials/0/otpAuth': null });
await engine.setLoginEnabled('acc1', false);
expect(
(bodyAt(7).methodCalls[0][1].update as Record<string, unknown>).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<Uint8Array>({
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('<html>oops</html>', { 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);
});
});
@@ -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<string, JmapCredential | undefined>;
readonly quotas?: { readonly maxDiskQuota?: unknown } | null;
readonly usedDiskQuota?: unknown;
}
interface JmapPermissions {
readonly '@type'?: unknown;
readonly enabledPermissions?: Record<string, boolean | undefined>;
readonly disabledPermissions?: Record<string, boolean | undefined>;
readonly permissions?: Record<string, boolean | undefined>;
}
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<string, boolean | undefined> | null;
}
interface AdminSession {
readonly authorization: string;
readonly accountId: string;
readonly apiPath: string;
}
interface SetResult {
readonly created?: Record<string, { readonly id?: string } | null>;
readonly notCreated?: Record<string, JmapError>;
readonly updated?: Record<string, unknown>;
readonly notUpdated?: Record<string, JmapError>;
readonly destroyed?: string[];
readonly notDestroyed?: Record<string, JmapError>;
}
interface QueryResult {
readonly ids?: string[];
}
interface GetResult<T> {
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<string, boolean> = {};
const disabledPermissions: Record<string, boolean> = {};
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<OpsSignInResult> {
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<EngineEmployee | null> {
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<EngineEmployee[]> {
const accounts = await this.getAll<JmapAccount>(
'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<boolean> {
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<EngineEmployee> {
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<void> {
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<void> {
const credentialId = await this.requirePasswordCredential(id);
await this.update('Account', id, {
[`credentials/${credentialId}/otpAuth`]: null,
});
}
async setLoginEnabled(id: string, enabled: boolean): Promise<void> {
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<void> {
const result = await this.set('Account', { destroy: [id] });
const failure = result.notDestroyed?.[id];
if (failure && failure.type !== 'notFound') throw refusal(failure);
}
async listMailingLists(): Promise<EngineMailingList[]> {
const lists = await this.getAll<JmapMailingList>('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<EngineMailingList> {
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<void> {
await this.update('MailingList', id, {
[`recipients/${member}`]: included ? true : null,
});
}
// ── 게이트웨이 대리 접근 (직원의 앱 비밀번호 · Sieve) ──────────────────────────
deleteAppPasswords(email: string): Promise<number> {
return this.accountSecurity.deleteAllAppPasswords(email);
}
async getLockRules(email: string): Promise<LockRulesState> {
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<void> {
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<void> {
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<JmapAccount | null> {
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<QueryResult>(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<JmapAccount[]> {
const [get] = await this.call([
[
'x:Account/get',
{
accountId: this.session().accountId,
ids,
properties: ACCOUNT_PROPERTIES,
},
'g',
],
]);
return this.expect<GetResult<JmapAccount>>(get, 'x:Account/get').list ?? [];
}
private async getAll<T>(
type: 'Account' | 'MailingList',
properties: readonly string[] | null,
): Promise<T[]> {
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<GetResult<T>>(get, `x:${type}/get`).list ?? [];
}
private async requireDomainId(): Promise<string> {
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<GetResult<{ id?: string; name?: unknown }>>(
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<string> {
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<string, unknown>,
): Promise<void> {
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<string, unknown>,
): Promise<SetResult> {
const [invocation] = await this.call([
[`x:${type}/set`, { accountId: this.session().accountId, ...args }, 's'],
]);
return this.expect<SetResult>(invocation, `x:${type}/set`);
}
private expect<T>(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<JmapInvocation[]> {
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<T>(
path: string,
init: RequestInit,
handle: (response: Response) => Promise<T>,
): Promise<T> {
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<void> {
await response.body?.cancel().catch(() => undefined);
}
/**
* 본문 JSON — 모양이 JSON 이 아니면 null(부르는 쪽이 malformed 로). 읽다가 끊기거나 시간이 다 되면 던진다(exchange 가 timeout · network 로)
*/
async function readJson(response: Response): Promise<unknown> {
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;
}
@@ -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: '<div><p>안녕하세요 — 알림</p></div>',
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 <postmaster@도메인>, 받는 사람 이름 <주소>, 자동 발송 표시', () => {
expect(header(raw, 'From')).toBe('ARA SYSTEM <postmaster@arasystem.test>');
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('<ara-notice-1@arasystem.test>');
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.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<string, Record<string, unknown>> }],
];
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');
});
});
@@ -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 <postmaster@<MAIL_DOMAIN>>`) */
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<string> {
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<JmapSetResult>(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<void> {
const responses = await this.jmap.call(user, (accountId) => [
['Email/set', { accountId, destroy: [emailId] }, 'd'],
]);
const result = expectResult<JmapSetResult>(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<string> {
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<JmapGetResult<JmapIdentity>>(
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<string> {
const responses = await this.jmap.call(user, (accountId) => [
['Mailbox/get', { accountId, ids: null, properties: ['role'] }, 'm'],
]);
const { list = [] } = expectResult<JmapGetResult<JmapMailbox>>(
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;
}
}
@@ -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 }));
+238 -29
View File
@@ -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<string> = 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<JmapSessionResource>(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<JmapSessionResource> {
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<JmapSessionResource>(response, 'session');
},
);
}
/**
* 그 계정의 비밀번호가 만료됐으면 PasswordExpiredException 으로 던진다(기억한 세션도 버린다) — 아니거나 알 수 없으면 아무것도 하지 않는다.
* 세션은 열렸는데 메일 메서드가 forbidden 일 때 부른다(13단계): 엔진은 비밀번호를 바꾼 직후 잠깐(0.5초 남짓) 옛 세션 자원을 주고,
* 이 서버는 세션을 5분 기억한다 — 그 사이에 만료된 계정이면 프로필 읽기가 forbidden 으로 끝난다
*/
async throwIfPasswordExpiredFor(user: string): Promise<void> {
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<boolean> {
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<AccountInfoReply>(
response,
'account-info',
);
return isPasswordExpiredPermissions(reply.permissions);
},
);
} catch {
return false;
}
}
/**
* 메일 기능이 없는 세션 — 그 계정의 권한이 "비밀번호 만료"의 다섯뿐이면 PasswordExpiredException(정식 주소)으로 던진다 (13단계 — 계약 6절).
* 권한을 읽지 못하거나 다른 모양이면 아무것도 하지 않는다(부르는 쪽이 지금처럼 MAIL_001 — 세션이 모자람)
*/
private async throwIfPasswordExpired(
user: string,
resource: JmapSessionResource,
): Promise<void> {
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<AccountJmapSession> {
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<ManagementSession> {
this.assertNotBlocked(user);
const cached = await this.cache.get<JmapSession>(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<boolean> {
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<AccountInfoReply>(
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<boolean> {
const { gatewayUser, gatewayPassword } = this.requireConfigured();
return this.exchange(
@@ -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) {}
@@ -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',
'메일 엔진 장애 · 저장하는 사이 엔진의 규칙이 계속 바뀜(세 번) · 이 서버가 모르는 새 판의 원본(되돌린 배포) — 같은 요청을 다시 보내면 된다',
@@ -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) {}
@@ -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) {}
@@ -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<FileScanListener>[0]) => listener?.(event),
};
}
/** 판정 소식의 크기 · 올린 때 (13단계에 더한 칸) */
const UPLOADED = {
size: 50_538_291,
uploadedAt: new Date('2026-10-30T06:58:00Z'),
};
async function codeOf(run: () => Promise<unknown>): Promise<string | null> {
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,
});
});
});
@@ -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,
});
});
}
+3
View File
@@ -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,
+28
View File
@@ -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단계 인증 코드 필요). 비밀번호는 저장하지 않는다.
+15
View File
@@ -137,6 +137,21 @@ export function buildMailSettings(
};
}
/**
* 메일 엔진 관리자 주소 — `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (소문자, 도메인이 없으면 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 ?? '')
@@ -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',
'메일 엔진 장애 (엔진이 상태를 알려 주지 않음 포함)',
@@ -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')
@@ -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)
@@ -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) {}
@@ -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 {
@@ -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) {}
+446
View File
@@ -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<void> {
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<void> {
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<void> {
const notes: string[] = [];
const fail = async (detail: string): Promise<void> => {
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<void> {
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(' · '),
});
}
}
+557
View File
@@ -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<void> {
const url = await io.ask('신고된 공개 링크 주소 (https://…/s/…): ');
// 우리 링크가 아니거나 없으면 null — DB 오류는 삼키지 않는다(그것을 "우리 링크 아님"으로 보이면 신고를 놓친다)
let record: Awaited<ReturnType<FileSharingService['findForOperator']>>;
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<void> {
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<void> {
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<void> {
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<string[] | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<SystemSettingsService['update']>[0],
detail: string,
): Promise<void> {
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)}`,
});
}
}
}
+680
View File
@@ -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<keyof AddressRows, string> = {
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<void> {
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<void> {
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<LockRules | null> {
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<void> {
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<void> {
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<AddressFootprint> {
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<AddressFootprint> {
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<void> {
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<void> {
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<boolean> {
const typed = (
await io.ask('지우려면 주소 전체를 다시 쳐 주세요: ')
).toLowerCase();
if (typed !== email) {
io.print('주소가 같지 않아 하지 않았습니다.');
return false;
}
return true;
}
}
@@ -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;
}
@@ -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<OpsAuditLogEntity>,
) {}
async record(entry: OpsAuditEntry, at: Date = new Date()): Promise<boolean> {
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;
}
}
}
+189
View File
@@ -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<number> {
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 <PROJECT_NAME>-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<string | null> {
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<boolean> {
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<void> {
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<void> {
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('메뉴의 번호를 넣어 주세요.');
}
}
}
File diff suppressed because it is too large Load Diff
+294
View File
@@ -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<number> {
return this.sessions.revokeAllOf(
email,
'ended-by-operator',
new Date(),
null,
);
}
/** 웹메일에서 2단계 인증을 켠 때 기록을 지운다 (2단계 해제 — 엔진이 기준이고 이 기록은 켠 때뿐이다) */
async forgetTwoFactor(email: string): Promise<void> {
await this.dataSource.manager.delete(TwoFactorSettingEntity, {
userEmail: email,
});
}
/** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */
async lastWebmailLogins(
emails: readonly string[],
): Promise<Map<string, Date>> {
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<AddressRows> {
return this.rowsOf(this.dataSource.manager, email, 'count');
}
/** 그 주소가 2단계 인증 필수의 예외 주소 목록에 있는가 (서버 설정 표 — 기억 없이 읽는다) */
async isTwoFactorExempt(email: string): Promise<boolean> {
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<AddressPurge> {
// 디스크에서 지울 파일 — 줄을 지우기 전에 이름을 읽어 둔다
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<boolean> {
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<boolean> {
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<AddressRows> {
const run = async (
entity:
| typeof LoginEventEntity
| typeof AppPasswordUsageEntity
| typeof TwoFactorSettingEntity
| typeof MailSignatureEntity
| typeof MailSignatureImageEntity
| typeof MailUserSettingsEntity
| typeof MailDirectoryEntryEntity
| typeof StoredFileEntity
| typeof ShareLinkEntity,
where: Record<string, string>,
): Promise<number> => {
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 }),
};
}
}
+151
View File
@@ -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<OpsSignInResult>;
/**
* 직원으로 다룰 수 있는 주소인가 — 사내 도메인이고 관리자 · 게이트웨이 · 사내 목록 · 막은 계정이 아니다 (엔진에 묻지 않는다).
* 아니면 명령은 "직원 주소가 아님"으로 멈춘다 — "엔진에 없음"과 가른다(없음은 기록만 남은 주소의 정리로 이어진다)
*/
isEmployeeAddress(email: string): boolean;
/** 주소로 직원 계정 찾기 (직원 주소가 아니거나 · User 가 아닌 계정(Group)이거나 · 없으면 null) */
findEmployee(email: string): Promise<EngineEmployee | null>;
/** 직원 계정 모두 (주소 차례 — User 계정 가운데 직원 주소만) */
listEmployees(): Promise<EngineEmployee[]>;
/** 그 주소를 쓰는 계정 · 목록이 엔진에 있는가 (직원이 아닌 것 포함 — 입사 때 겹침 확인) */
addressInUse(email: string): Promise<boolean>;
/** 입사 — 직원 역할 · 임시 비밀번호 · 만료(첫 로그인 때 바꾸기) · 메일함 용량(없으면 무제한) */
createEmployee(input: {
readonly localPart: string;
readonly name: string;
readonly quotaBytes: number | null;
readonly tempPassword: string;
}): Promise<EngineEmployee>;
/** 임시 비밀번호 + 만료 — 2단계 인증 · 앱 비밀번호는 그대로 둔다 */
resetPassword(id: string, tempPassword: string): Promise<void>;
/** 2단계 인증(OTP)을 지운다 — 비밀번호는 그대로 */
removeTwoFactor(id: string): Promise<void>;
/** 로그인 권한(authenticate) 빼기 · 되돌리기 — 메일은 계속 받는다 */
setLoginEnabled(id: string, enabled: boolean): Promise<void>;
/** 계정 지우기 — 그 계정의 메일이 영구 삭제된다 */
deleteAccount(id: string): Promise<void>;
listMailingLists(): Promise<EngineMailingList[]>;
createMailingList(input: {
readonly localPart: string;
readonly name: string;
readonly members: readonly string[];
}): Promise<EngineMailingList>;
setMailingListMember(
id: string,
member: string,
included: boolean,
): Promise<void>;
/** 앱 비밀번호를 모두 지운다 → 지운 수 (대리 접근 — 잠겼거나 비밀번호가 만료된 계정은 엔진이 막는다) */
deleteAppPasswords(email: string): Promise<number>;
/** 지금 걸린 잠금 Sieve (대리 접근) */
getLockRules(email: string): Promise<LockRulesState>;
/** 잠금 Sieve 를 건다 — 직원의 필터 · 부재중 · 차단과 한 스크립트(엔진은 스크립트 하나만 켠다) */
applyLockRules(email: string, rules: LockRules): Promise<void>;
/** 잠금 Sieve 를 뗀다 — 넘기기 규칙을 빼고 자동 응답을 끈다 */
clearLockRules(email: string): Promise<void>;
}
/** 관리자 계정 주소 — `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (운영 모듈이 설정에서 만든다) */
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';
}
}
+223
View File
@@ -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();
});
});
+148
View File
@@ -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<string>;
/** 화면에 보이지 않게 묻기 (관리자 비밀번호) — 지우지 않은 그대로 */
askSecret(question: string): Promise<string>;
/** y/N — y · yes · 예 · ㅇ 만 예, 그 밖(빈 값 포함)은 아니오 */
confirm(question: string): Promise<boolean>;
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 <backend> 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<string> {
this.output.write(question);
return (await this.nextLine()).trim();
}
async askSecret(question: string): Promise<string> {
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<boolean> {
this.output.write(`${question} (y/N) `);
return isYes(await this.nextLine());
}
close(): void {
this.rl.close();
}
private nextLine(): Promise<string> {
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 });
});
}
}
+128
View File
@@ -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<string | null> {
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<EngineEmployee | null> {
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<boolean> {
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}`);
}
+132
View File
@@ -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<string> = 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';
}
+141
View File
@@ -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<string>(key)) ?? '',
},
JmapAccountSecurityProvider,
JmapFilterScriptStore,
{ provide: OPS_ENGINE, useClass: JmapOpsEngine },
// 파일 · 링크 — 링크 끊기 · 보관 기간 맞추기 · 퇴사 마무리의 파일 지우기 (검사 차례는 돌리지 않는다)
{
provide: FILE_STORAGE_SETTINGS,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
buildFileStorageSettings((key) => config.get<string>(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<string>(key)),
},
FileSharingRepository,
FileSharingService,
{
provide: SignatureImageStorage,
useFactory: () => new SignatureImageStorage(),
},
OpsDataService,
OpsAuditService,
AccountCommands,
LockCommands,
AdminCommands,
OpsCli,
],
})
export class OpsModule {}
+32
View File
@@ -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;
}
}
}
@@ -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 {}
@@ -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<Promise<string>, [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<Promise<string>, [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('<br');
});
it('지우기 실패는 false — 던지지 않는다', async () => {
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);
});
});
@@ -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<string | null> {
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<string | null> {
return this.deliver(email, options.name, (recipient) =>
twoFactorResetNotice(recipient, options.at ?? new Date()),
);
}
/** ③ 위험 파일 판정 → 넣은 메일 id (넣지 못하면 null) */
async notifyDangerousFile(
email: string,
file: DangerousFileInfo,
): Promise<string | null> {
return this.deliver(email, null, (recipient) =>
dangerousFileNotice(recipient, file),
);
}
/** 넣은 알림을 지운다 (운영 도구 — 엔진 바꾸기가 실패했을 때) → 지웠으면 true. 실패해도 던지지 않는다(warn) */
async remove(email: string, emailId: string): Promise<boolean> {
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<string | null> {
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;
}
}
}
@@ -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 <postmaster@도메인>) */
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<string>;
/** 방금 넣은 알림을 지운다 (운영 도구 — 넣은 뒤 엔진 바꾸기가 실패했을 때) */
remove(user: string, emailId: string): Promise<void>;
/** 그 직원의 표시 이름 — 엔진에 없거나 읽지 못하면 주소의 @ 앞 */
displayNameOf(user: string): Promise<string>;
}
@@ -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(
'<strong>웹메일 › 설정 › 계정 › 앱 비밀번호</strong>에서 새로 만들어 주세요.',
);
});
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(
'<strong>웹메일 › 설정 › 계정 › 2단계 인증</strong>에서 다시 켜 주세요.',
);
});
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(
'<strong>웹메일 › 설정 › 메일 › 대용량 첨부</strong>에서 볼 수 있습니다.',
);
});
it.each(ALL)(
'%s — 링크가 없다 (회사 보안 알림에는 링크가 없다)',
(_label, notice) => {
expect(notice.html).not.toMatch(/<a[\s>]/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: '<b>김&아라</b>', supportContact: '"IT" <it@x>' },
{
name: '<img src=x onerror=alert(1)>.zip',
size: 1024,
uploadedAt: AT,
},
);
expect(notice.html).not.toContain('<b>');
expect(notice.html).not.toContain('<img');
expect(notice.html).toContain('&lt;b&gt;김&amp;아라&lt;/b&gt;');
expect(notice.html).toContain('&lt;img src=x onerror=alert(1)&gt;.zip');
expect(notice.html).toContain('&quot;IT&quot; &lt;it@x&gt;');
expect(notice.text).toContain('<b>김&아라</b> 님,');
expect(notice.text).toContain('파일: <img src=x onerror=alert(1)>.zip');
});
it('연락처를 정하지 않았으면 그 줄(HTML 은 앞의 <br> 와 함께)을 뺀다', () => {
const notice = passwordResetNotice(
{ name: '김아라', supportContact: null },
AT,
);
expect(notice.html).not.toContain('<br>');
expect(notice.html).toContain(
'직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.</p>',
);
expect(notice.text).toContain(
`직접 요청한 일이 아니면 바로 IT 담당에게 알려 주세요.\n\n${NOTICE_FOOTER}`,
);
const blank = passwordResetNotice(
{ name: '김아라', supportContact: ' ' },
AT,
);
expect(blank.html).not.toContain('<br>');
});
});
@@ -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 에 넣을 때 이스케이프한다. 연락처가 없으면 그 줄(과 앞의 <br>)을 뺀다
*/
/** 알림의 종류 — 메일 머리글 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function todoHtml(parts: readonly TodoPart[]): string {
return parts
.map((part) =>
'strong' in part
? `<strong>${escapeHtml(part.strong)}</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]) =>
`<tr><th style="${STYLE_TH}">${escapeHtml(label)}</th><td style="${STYLE_TD}">${escapeHtml(value)}</td></tr>`,
)
.join('');
const html =
`<div style="${STYLE_WRAPPER}">` +
`<p style="${STYLE_GREETING}">${escapeHtml(body.greeting)}</p>` +
`<table style="${STYLE_TABLE}"><tbody>${rowsHtml}</tbody></table>` +
`<p style="${STYLE_TODO_TITLE}">${TODO_TITLE}</p>` +
`<p style="${STYLE_TODO}">${todoHtml(body.todo)}</p>` +
`<p style="${STYLE_CONFIRM}">${escapeHtml(body.confirm)}${contact ? `<br>${escapeHtml(contact)}` : ''}</p>` +
`<p style="${STYLE_FOOTER}">${escapeHtml(NOTICE_FOOTER)}</p>` +
`</div>`;
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,
);
}
@@ -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;
}
@@ -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<SupportContactDto> {
return { text: await this.settings.supportContact() };
}
}
@@ -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;
}
@@ -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 {}
@@ -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<TwoFactorPolicy>;
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<SystemSettingsEntity>,
) {}
async get(): Promise<SystemSettings> {
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<string | null> {
try {
return (await this.get()).supportContact;
} catch (error) {
this.logger.error(
`[SYS_001] 문의 연락처를 읽지 못해 없는 것으로 답합니다: ${errorMessage(error)}`,
);
return null;
}
}
/** 기억을 건너뛰고 다시 읽는다 (운영 도구가 바꾸기 전 · 뒤에 보인다) */
async reload(): Promise<SystemSettings> {
this.cached = null;
return this.get();
}
/** 보낸 칸만 바꾸고 바꾼 값을 돌려준다 (운영 도구) — 값의 검사는 부르는 쪽이 한다 */
async update(patch: SystemSettingsPatch, at: Date): Promise<SystemSettings> {
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();
}
}
@@ -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> = {}): 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<SystemSettingsEntity> | 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<SystemSettingsEntity>) => {
row = { ...row, ...next };
return Promise.resolve();
}),
};
service = new SystemSettingsService(
rows as unknown as Repository<SystemSettingsEntity>,
);
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 담당' });
});
});
@@ -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<SessionRestriction, 'twoFactorSetup'> | 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 };
}
@@ -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) {}