feat(backend): 앱 전용 토큰 로그인 · 갱신 · 로그아웃 경로를 만든다

계약 1절 "앱 로그인"이다(웹의 쿠키를 앱에 쓰지 않는다). 세션 규칙은 웹과 같고 토큰을 나르는
길만 다르다.

- POST /api/auth/app/login · refresh · logout — 같은 AuthService.login 을 지나 실패 카운터 ·
  2단계 코드 가드 · 앱 비밀번호 모양 거절 · 관리자 차단이 웹과 같고, 로그인 요청 수 한도는
  웹 · 앱이 한 칸을 함께 쓴다. 토큰 응답은 private, no-store
- Bearer 는 앱 표시(cli: app)와 UUID sid 가 있는 액세스 토큰만, 쿠키는 웹 토큰만 — 리프레시 토큰 ·
  세션 표의 client 도 맞아야 하고 다르면 401(재사용으로 보지 않음)
- 앱 세션의 갱신 유예는 그 세션을 끝낸다(app-refresh-grace — 응답을 잃었거나 토큰을 훔친 것이다)
- 지금 세션은 앱이면 액세스 토큰의 sid(비밀번호 바꾸기 · 로그인된 기기 · 앱 logout), 로그아웃은
  리프레시 토큰이 틀리면 sid 로 끝낸다
- 세션 표 client 칸(마이그레이션 AddAppSessionClient — 기본 web), 세션 목록 항목에 client,
  앱 이름 "ARA SYSTEM 앱"은 client · method 로만(꾸민 User-Agent 로 앱처럼 보이지 않게),
  로그인 기록 method app
- 운영 도구: 현황의 마지막 로그인이 앱도 세고, 세션 끊기 글 · 직원 알림 메일이 앱을 말한다
- Swagger 에 Bearer 를 등록하고 쿠키로 보호하는 작업마다 "또는"으로 붙인다

리뷰 반영: 유예에서 앱 세션 끊기, 앱 이름을 진짜 클라이언트로만, sid 없는 앱 토큰 거절,
빈 · 너무 긴 리프레시 토큰은 401(로그아웃은 sid 로), 시험(웹 쿠키 + 앱 logout 등).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 16:38:17 +09:00
parent 2bb7fa4cdb
commit 6ee6c71ace
48 changed files with 2783 additions and 242 deletions
@@ -32,7 +32,7 @@ export class LoginEventQueryDto extends CursorPaginationQueryDto {
export class DeviceDto {
@ApiProperty({
description:
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet — 모르면 null',
'Edge · Chrome · Firefox · Safari · Whale · Samsung Internet · ARA SYSTEM 앱(모바일 앱 — 계약 1절 "앱 로그인") — 모르면 null',
type: String,
nullable: true,
example: 'Chrome',
@@ -76,18 +76,18 @@ export class LoginEventDto {
@ApiProperty({
description:
'webmail · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
enum: ['webmail', 'imap', 'smtp'],
'webmail · app(모바일 앱 — 계약 1절 "앱 로그인") · imap(메일 프로그램 받기) · smtp(메일 프로그램 보내기)',
enum: ['webmail', 'app', 'imap', 'smtp'],
example: 'webmail',
})
method!: 'webmail' | 'imap' | 'smtp';
method!: 'webmail' | 'app' | 'imap' | 'smtp';
@ApiProperty({ description: '성공했는가', example: true })
isSuccess!: boolean;
@ApiProperty({
description:
'웹메일 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
'웹메일 · 앱 성공이 2단계 인증 코드까지 거쳤는가 (메일 프로그램은 false)',
example: true,
})
isTwoFactor!: boolean;
@@ -113,7 +113,7 @@ export class LoginEventDto {
@ApiProperty({
description:
'웹메일의 브라우저 · 운영체제 — 메일 프로그램이거나 모르면 null',
'웹메일 · 앱의 브라우저(앱은 ARA SYSTEM 앱) · 운영체제 — 메일 프로그램이거나 모르면 null',
type: DeviceDto,
nullable: true,
})
@@ -15,8 +15,11 @@ const APP_PASSWORD_ID_COLUMN_LENGTH = 16;
/** 겹침 거르기 키 — SHA-256 의 base64url (43자) */
const DEDUPE_KEY_LENGTH = 43;
/** 로그인 방식 — 웹메일 · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
export type LoginEventMethod = 'webmail' | 'imap' | 'smtp';
/** 로그인 방식 — 웹메일 · 앱(모바일 — 계약 1절 "앱 로그인") · 메일 프로그램 받기(IMAP) · 메일 프로그램 보내기(SMTP) */
export type LoginEventMethod = 'webmail' | 'app' | 'imap' | 'smtp';
/** 서버가 판정한 로그인의 방식 — 웹메일 · 앱 (메일 프로그램은 엔진 웹훅이 알려 준다) */
export type SignInMethod = Extract<LoginEventMethod, 'webmail' | 'app'>;
/**
* 실패 까닭 (계약 5절 "로그인 기록"):
@@ -31,7 +34,7 @@ export type LoginFailureReason =
/**
* 로그인 기록 (계약 5절 "로그인 기록") — 90일 보존(날마다 정리 — login-events.cleanup.ts).
* - 웹메일: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
* - 웹메일 · 앱: 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006) — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
* - 메일 프로그램: 엔진 웹훅의 IMAP · SMTP 이벤트 (mail-engine-events.service.ts) — `dedupe_key` 로 엔진이 다시 보낸 묶음을 거른다
* 비밀번호 · 코드 · 앱 비밀번호 값은 없다. 앱 비밀번호 이름은 그때의 이름이다(지운 뒤에도 남는다)
*/
@@ -56,7 +59,7 @@ export class LoginEventEntity {
@Column({ name: 'is_success', type: 'boolean' })
isSuccess!: boolean;
/** 웹메일 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
/** 웹메일 · 앱 성공이 코드까지 거쳤는가 (메일 프로그램은 늘 false) */
@Column({ name: 'is_two_factor', type: 'boolean' })
isTwoFactor!: boolean;
@@ -71,7 +74,7 @@ export class LoginEventEntity {
@Column({ type: 'varchar', length: IP_COLUMN_LENGTH, nullable: true })
ip!: string | null;
/** 웹메일의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
/** 웹메일 · 앱의 User-Agent 원문(앞 512자) — 화면에는 짧은 이름만. 메일 프로그램은 null */
@Column({
name: 'user_agent',
type: 'varchar',
@@ -11,7 +11,10 @@ import {
type AuthenticatedUser,
} from '@/common/decorators/current-user.decorator';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { describeUserAgent } from '@/shared/utils/user-agent';
import {
describeAppUserAgent,
describeUserAgent,
} from '@/shared/utils/user-agent';
import { LOGIN_EVENT_RETENTION_DAYS } from './account-security.constants';
import {
LoginEventPageDto,
@@ -30,15 +33,21 @@ function toDto(event: LoginEventEntity): LoginEventDto {
isTwoFactor: event.isTwoFactor,
failureReason: event.failureReason,
ip: event.ip,
// 웹메일 · 앱만 기기를 안다(User-Agent) — 메일 프로그램은 엔진이 알려 주지 않는다.
// 앱 이름(ARA SYSTEM 앱)은 서버가 앱 경로로 판정한 기록에만 — 웹메일 기록의 User-Agent 가 앱 꼴이어도 붙이지 않는다
device:
event.method === 'webmail' ? describeUserAgent(event.userAgent) : null,
event.method === 'app'
? describeAppUserAgent(event.userAgent)
: event.method === 'webmail'
? describeUserAgent(event.userAgent)
: null,
appPassword: event.appPasswordId
? { id: event.appPasswordId, name: event.appPasswordName }
: null,
};
}
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일과 메일 프로그램(IMAP · SMTP)의 로그인
// 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱과 메일 프로그램(IMAP · SMTP)의 로그인
@ApiTags('Account security')
@ApiCookieAuth()
@UseGuards(JwtAuthGuard)
@@ -51,7 +60,7 @@ export class LoginEventsController {
@ApiOperation({
summary: `로그인 기록 — 최근 ${LOGIN_EVENT_RETENTION_DAYS}일 · 최근 차례 · 커서`,
description:
'웹메일: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
'웹메일 · 앱: 서버가 판정한 로그인 시도(성공 · 비밀번호 틀림 · 코드 틀림). 한도로 막은 요청 · 갱신은 남기지 않는다. ' +
'메일 프로그램: 엔진이 알려 준 IMAP · SMTP 로그인(엔진이 다시 뜨거나 서버가 5분 넘게 받지 못한 동안의 기록은 빠진다). ' +
`기록은 ${LOGIN_EVENT_RETENTION_DAYS}일 보존. 커서는 같은 isSuccess 로만 쓴다(다르면 400 VAL_001).`,
})
@@ -10,7 +10,7 @@ import { LoginEventsService, retentionStart } from './login-events.service';
const ARA = 'ara.kim@arasystem.test';
const NOW = new Date('2026-10-31T03:00:00.000Z');
describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계약 5절)', () => {
describe('LoginEventsService.recordSignIn (웹메일 · 앱 로그인 기록 — 계약 5절)', () => {
let events: { insert: jest.Mock };
let directory: { hasEmail: jest.Mock };
let service: LoginEventsService;
@@ -34,8 +34,9 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
afterEach(() => jest.restoreAllMocks());
const attempt = (
overrides: Partial<Parameters<LoginEventsService['recordWebmail']>[0]>,
overrides: Partial<Parameters<LoginEventsService['recordSignIn']>[0]>,
) => ({
method: 'webmail' as const,
email: ARA,
isSuccess: false,
isTwoFactor: false,
@@ -46,7 +47,7 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
});
it('성공 · 코드 틀림은 사내 목록을 묻지 않고 남긴다 — User-Agent 원문은 512자까지', async () => {
await service.recordWebmail(
await service.recordSignIn(
attempt({
isSuccess: true,
isTwoFactor: true,
@@ -55,7 +56,7 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
}),
NOW,
);
await service.recordWebmail(attempt({ failureReason: 'wrongCode' }), NOW);
await service.recordSignIn(attempt({ failureReason: 'wrongCode' }), NOW);
expect(directory.hasEmail).not.toHaveBeenCalled();
expect(events.insert).toHaveBeenCalledTimes(2);
@@ -75,21 +76,21 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
it('비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 없는 주소의 시도는 버린다, 읽지 못하면 남긴다', async () => {
directory.hasEmail.mockResolvedValueOnce(false);
await service.recordWebmail(
await service.recordSignIn(
attempt({ email: 'nobody@arasystem.test' }),
NOW,
);
expect(events.insert).not.toHaveBeenCalled();
directory.hasEmail.mockRejectedValueOnce(new BusinessException('MAIL_001'));
await service.recordWebmail(attempt({}), NOW);
await service.recordSignIn(attempt({}), NOW);
expect(events.insert).toHaveBeenCalledTimes(1);
});
it('남기지 못해도 던지지 않는다 — error 로그(가린 주소)', async () => {
events.insert.mockRejectedValue(new Error('db down'));
await expect(
service.recordWebmail(
service.recordSignIn(
attempt({ isSuccess: true, failureReason: null }),
NOW,
),
@@ -98,6 +99,25 @@ describe('LoginEventsService.recordWebmail (웹메일 로그인 기록 — 계
expect(errors.join('\n')).not.toContain(ARA);
});
it('앱 로그인은 방식 app 으로 남긴다 — User-Agent 원문도 둔다 (계약 1절 "앱 로그인")', async () => {
await service.recordSignIn(
attempt({
method: 'app',
isSuccess: true,
failureReason: null,
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
}),
NOW,
);
expect(events.insert).toHaveBeenCalledWith(
expect.objectContaining({
method: 'app',
isSuccess: true,
userAgent: 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)',
}),
);
});
it('보존 기간은 90일', () => {
expect(retentionStart(NOW)).toEqual(new Date('2026-08-02T03:00:00.000Z'));
});
@@ -19,6 +19,7 @@ import {
import {
LoginEventEntity,
type LoginFailureReason,
type SignInMethod,
} from './login-event.entity';
import {
decodeLoginEventCursor,
@@ -28,8 +29,10 @@ import {
const DAY_MS = 24 * 60 * 60 * 1000;
/** 웹메일 로그인 시도 하나 — 서버가 판정한 것 */
export interface WebmailLoginAttempt {
/** 웹메일 · 앱 로그인 시도 하나 — 서버가 판정한 것 */
export interface SignInAttempt {
/** webmail(웹 — 쿠키) · app(모바일 앱 — 계약 1절 "앱 로그인") */
readonly method: SignInMethod;
readonly email: string;
readonly isSuccess: boolean;
/** 성공이 코드까지 거쳤는가 */
@@ -56,7 +59,7 @@ export function retentionStart(now: Date): Date {
}
/**
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
* 로그인 기록 (계약 5절 "로그인 기록") — 웹메일 · 앱 로그인(AuthService)과 메일 프로그램 로그인(엔진 웹훅)을 한 표에 둔다.
*
* - 남기는 실패는 사내 목록에 있는 주소만 — 없는 주소의 실패는 버린다(공격자가 아무 주소나 뿌려 표를 채우지 못하게, 계정이 나중에 생겨도
* 그 전의 시도가 보이지 않게). 사내 목록을 읽지 못하면 남긴다(보안 기록을 잃는 쪽보다 낫다). 코드 틀림 · 성공은 늘 진짜 계정이라 바로 남긴다
@@ -74,9 +77,9 @@ export class LoginEventsService {
private readonly directory: DirectoryService,
) {}
/** 웹메일 로그인 시도를 남긴다 — 던지지 않는다 */
async recordWebmail(
attempt: WebmailLoginAttempt,
/** 웹메일 · 앱 로그인 시도를 남긴다 — 던지지 않는다 */
async recordSignIn(
attempt: SignInAttempt,
now: Date = new Date(),
): Promise<void> {
const address = normalizeEmail(attempt.email);
@@ -89,7 +92,7 @@ export class LoginEventsService {
await this.events.insert({
userEmail: address,
occurredAt: now,
method: 'webmail',
method: attempt.method,
isSuccess: attempt.isSuccess,
isTwoFactor: attempt.isTwoFactor,
failureReason: attempt.failureReason,
@@ -101,7 +104,7 @@ export class LoginEventsService {
});
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 웹메일 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
`[SYS_001]${traceTag()} ${attempt.method === 'app' ? '앱' : '웹메일'} 로그인 기록을 남기지 못했습니다: user=${maskEmail(address)}, reason=${errorMessage(error)}`,
);
}
}
@@ -5,6 +5,7 @@ import { credentialNumberToId } from './app-password';
import type {
LoginEventMethod,
LoginFailureReason,
SignInMethod,
} from './login-event.entity';
/**
@@ -37,7 +38,8 @@ export type EngineLoginOutcome = 'success' | 'failed' | 'mfa-required';
export interface EngineLoginEvent {
readonly dedupeKey: string;
readonly occurredAt: Date;
readonly method: Exclude<LoginEventMethod, 'webmail'>;
/** 메일 프로그램의 방식 — imap · smtp (웹메일 · 앱은 서버가 판정해 남긴다) */
readonly method: Exclude<LoginEventMethod, SignInMethod>;
readonly outcome: EngineLoginOutcome;
/** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */
readonly accountName: string;
@@ -0,0 +1,70 @@
import { startCpuTimer } from '@/../test/cpu-time';
import { parseBearerAuthorization, presentedAccessToken } from './access-token';
describe('parseBearerAuthorization (앱의 Authorization: Bearer — 계약 1절 "앱 로그인")', () => {
it('Bearer 면 토큰 — 방식은 대소문자를 가리지 않고, 사이의 공백 여럿 · 앞뒤 공백은 지운다', () => {
expect(parseBearerAuthorization('Bearer a.b.c')).toBe('a.b.c');
expect(parseBearerAuthorization('bearer a.b.c')).toBe('a.b.c');
expect(parseBearerAuthorization('BEARER a.b.c ')).toBe('a.b.c');
});
it('머리가 없거나 다른 방식(Basic 등)이면 null — 쿠키를 본다', () => {
expect(parseBearerAuthorization(undefined)).toBeNull();
expect(parseBearerAuthorization('')).toBeNull();
expect(parseBearerAuthorization('Basic dXNlcjpwdw==')).toBeNull();
expect(parseBearerAuthorization('Bearers a.b.c')).toBeNull();
});
it("Bearer 인데 토큰이 비었거나 공백이 섞였으면 '' — Bearer 로 왔으니 쿠키로 넘어가지 않는다", () => {
expect(parseBearerAuthorization('Bearer')).toBe('');
expect(parseBearerAuthorization('Bearer ')).toBe('');
expect(parseBearerAuthorization('Bearer a.b.c d.e.f')).toBe('');
});
it('아주 긴 머리도 길이에 비례해 끝난다 (머리 상한 16KB 의 몇 배)', () => {
const long = `Bearer ${'a'.repeat(64_000)}`;
const spaced = `Bearer ${' '.repeat(64_000)}x ${'b'.repeat(64_000)}`;
const cpuMs = startCpuTimer();
for (let i = 0; i < 100; i += 1) {
parseBearerAuthorization(long);
parseBearerAuthorization(spaced);
}
// 스레드 CPU 시간 — 지금 4~6ms(개발 컨테이너). 한도는 그 10배 남짓이고, 제곱이면 64KB 에서 수십 초가 든다 (testing 규칙)
expect(cpuMs()).toBeLessThan(100);
});
});
describe('presentedAccessToken (가드가 읽는 액세스 토큰과 그 길)', () => {
it('Authorization: Bearer 가 있으면 그것만 본다 — 같은 요청의 액세스 쿠키는 보지 않는다', () => {
expect(
presentedAccessToken({
headers: { authorization: 'Bearer app.token' },
cookies: { access_token: 'web.token' },
}),
).toEqual({ transport: 'bearer', token: 'app.token' });
// 토큰이 빈 Bearer 도 쿠키로 넘어가지 않는다 — 토큰 없음(가드가 AUTH_001)
expect(
presentedAccessToken({
headers: { authorization: 'Bearer ' },
cookies: { access_token: 'web.token' },
}),
).toEqual({ transport: 'bearer', token: null });
});
it('Bearer 가 없으면 액세스 쿠키(웹) — 다른 방식의 Authorization 은 상관하지 않는다', () => {
expect(
presentedAccessToken({
headers: { authorization: 'Basic dXNlcjpwdw==' },
cookies: { access_token: 'web.token' },
}),
).toEqual({ transport: 'cookie', token: 'web.token' });
expect(presentedAccessToken({ headers: {}, cookies: {} })).toEqual({
transport: 'cookie',
token: null,
});
expect(presentedAccessToken({ headers: {} })).toEqual({
transport: 'cookie',
token: null,
});
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { IncomingHttpHeaders } from 'http';
import { readCookie } from '@/shared/utils/cookies';
import { ACCESS_TOKEN_COOKIE } from './auth.constants';
/**
* 요청이 액세스 토큰을 실어 온 길 (계약 1절 "앱 로그인") — 웹은 HttpOnly 쿠키, 앱은 `Authorization: Bearer`.
* 가드(JwtStrategy)는 길과 토큰의 표시(`cli`)가 맞는지 본다 — 앱 토큰은 Bearer 로만, 웹 토큰은 쿠키로만 받는다
*/
export type AccessTokenTransport = 'cookie' | 'bearer';
/** 요청이 실어 온 액세스 토큰 — 그 길에 토큰이 없거나 모양이 틀리면 token 은 null */
export interface PresentedAccessToken {
readonly transport: AccessTokenTransport;
readonly token: string | null;
}
/** 요청 대역 — 머리글과 cookie-parser 가 채운 쿠키만 본다 */
export interface AccessTokenRequest {
readonly headers: IncomingHttpHeaders;
readonly cookies?: unknown;
}
/** Authorization 머리의 인증 방식 — 대소문자를 가리지 않는다 (RFC 9110) */
const BEARER_SCHEME = 'bearer';
/** 공백 한 글자 — 방식과 토큰 사이 (정규식은 한 글자씩만 본다 — 입력 길이에 비례) */
const WHITESPACE = /\s/;
/**
* Authorization 머리 → Bearer 토큰.
* - Bearer 가 아니면(머리 없음 · Basic 등 다른 방식) null — 쿠키를 본다
* - Bearer 인데 토큰이 비었거나 공백이 섞였으면 '' — Bearer 로 왔으니 쿠키로 넘어가지 않는다(가드가 AUTH_001)
* 토큰 원문은 어디에도 남기지 않는다
*/
export function parseBearerAuthorization(
header: string | undefined,
): string | null {
if (header === undefined) return null;
const value = header.trim();
const space = value.search(WHITESPACE);
const scheme = space === -1 ? value : value.slice(0, space);
if (scheme.toLowerCase() !== BEARER_SCHEME) return null;
const credentials = space === -1 ? '' : value.slice(space).trim();
return WHITESPACE.test(credentials) ? '' : credentials;
}
/**
* 요청이 실어 온 액세스 토큰 — `Authorization: Bearer` 가 있으면 그것만 본다(앱 — 같은 요청의 쿠키는 보지 않는다),
* 없으면 액세스 쿠키(웹). 두 길을 섞어 읽지 않아 한쪽이 틀리면 다른 쪽으로 넘어가지 않는다
*/
export function presentedAccessToken(
request: AccessTokenRequest,
): PresentedAccessToken {
const bearer = parseBearerAuthorization(request.headers.authorization);
if (bearer !== null) {
return { transport: 'bearer', token: bearer === '' ? null : bearer };
}
return {
transport: 'cookie',
token: readCookie(request, ACCESS_TOKEN_COOKIE) ?? null,
};
}
@@ -0,0 +1,225 @@
import { GUARDS_METADATA, HEADERS_METADATA } from '@nestjs/common/constants';
import type { Request } from 'express';
import { AppAuthController } from './app-auth.controller';
import { AuthController } from './auth.controller';
import type { AuthService } from './auth.service';
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
const USER = { email: 'ara.kim@arasystem.test', name: '김아라' };
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null };
/** 서비스가 만든 두 토큰 (SessionTokens) */
const TOKENS = {
accessToken: 'new.access',
refreshToken: 'new.refresh',
refreshTtlSeconds: 1_209_600,
accessExpiresAt: new Date('2026-11-06T03:00:00.000Z'),
refreshExpiresAt: new Date('2026-11-20T02:00:00.000Z'),
};
/** 앱 응답의 tokens — 시각은 ISO 8601 UTC */
const APP_TOKENS = {
accessToken: 'new.access',
accessExpiresAt: '2026-11-06T03:00:00.000Z',
refreshToken: 'new.refresh',
refreshExpiresAt: '2026-11-20T02:00:00.000Z',
};
/** 요청 대역 — Authorization · User-Agent 머리글 */
function fakeRequest(headers: Record<string, string> = {}) {
return {
headers,
get: (name: string) => headers[name.toLowerCase()],
} as unknown as Request;
}
/** 핸들러 함수 객체 — Nest 는 라우트 메타데이터(가드 · 한도 · 머리글)를 이 함수에 붙인다 */
function handlerOf(
target: { prototype: object },
name: string,
): (...args: unknown[]) => unknown {
const descriptor = Object.getOwnPropertyDescriptor(target.prototype, name);
return descriptor?.value as (...args: unknown[]) => unknown;
}
describe('AppAuthController (앱 전용 토큰 경로 — 계약 1절 "앱 로그인")', () => {
let auth: { login: jest.Mock; refresh: jest.Mock; logoutApp: jest.Mock };
let controller: AppAuthController;
beforeEach(() => {
auth = {
login: jest.fn(),
refresh: jest.fn(),
logoutApp: jest.fn().mockResolvedValue(undefined),
};
controller = new AppAuthController(auth as unknown as AuthService);
});
it('가드가 없다 — 로그인 전 · 액세스 토큰이 끝난 뒤에 부른다', () => {
for (const name of ['login', 'refresh', 'logout']) {
expect(
Reflect.getMetadata(
GUARDS_METADATA,
handlerOf(AppAuthController, name),
),
).toBeUndefined();
}
expect(
Reflect.getMetadata(GUARDS_METADATA, AppAuthController),
).toBeUndefined();
});
it('토큰이 든 응답(로그인 · 갱신)은 Cache-Control: private, no-store', () => {
for (const name of ['login', 'refresh']) {
expect(
Reflect.getMetadata(
HEADERS_METADATA,
handlerOf(AppAuthController, name),
),
).toEqual([{ name: 'Cache-Control', value: 'private, no-store' }]);
}
});
it('로그인 요청 수 상한은 웹 로그인과 같은 값이고 같은 칸을 센다 — 같은 IP 면 두 경로의 키가 같다', () => {
const app = handlerOf(AppAuthController, 'login');
const web = handlerOf(AuthController, 'login');
expect(Reflect.getMetadata('THROTTLER:LIMITdefault', app)).toBe(60);
expect(Reflect.getMetadata('THROTTLER:TTLdefault', app)).toBe(60_000);
type KeyGenerator = (
context: unknown,
tracker: string,
name: string,
) => string;
const appKey = Reflect.getMetadata(
'THROTTLER:KEY_GENERATORdefault',
app,
) as KeyGenerator;
const webKey = Reflect.getMetadata(
'THROTTLER:KEY_GENERATORdefault',
web,
) as KeyGenerator;
expect(appKey({}, '203.0.113.10', 'default')).toBe(
webKey({}, '203.0.113.10', 'default'),
);
expect(appKey({}, '203.0.113.10', 'default')).not.toBe(
appKey({}, '203.0.113.11', 'default'),
);
// 다른 앱 경로는 전역 기본값
expect(
Reflect.getMetadata(
'THROTTLER:LIMITdefault',
handlerOf(AppAuthController, 'refresh'),
),
).toBeUndefined();
});
it('로그인은 웹과 같은 서비스 함수에 앱 표시(sessionClient app) · User-Agent · 코드를 넘기고, signedIn 이면 tokens 를 싣는다', async () => {
auth.login.mockResolvedValue({
status: 'signedIn',
user: USER,
tokens: TOKENS,
state: { restriction: 'passwordChange', twoFactorDeadline: null },
});
const result = await controller.login(
{ email: USER.email, password: 'pw', otpCode: '123456' },
'203.0.113.10',
fakeRequest({ 'user-agent': APP_UA }),
);
expect(auth.login).toHaveBeenCalledWith(USER.email, 'pw', '203.0.113.10', {
otpCode: '123456',
userAgent: APP_UA,
sessionClient: 'app',
});
expect(result).toEqual({
status: 'signedIn',
...USER,
restriction: 'passwordChange',
twoFactorDeadline: null,
tokens: APP_TOKENS,
});
});
it('2단계 인증이 필요하면 tokens: null — 웹처럼 email · name 도 null', async () => {
auth.login.mockResolvedValue({
status: 'twoFactorRequired',
user: null,
tokens: null,
});
await expect(
controller.login(
{ email: USER.email, password: 'pw' },
'203.0.113.10',
fakeRequest(),
),
).resolves.toEqual({
status: 'twoFactorRequired',
email: null,
name: null,
...UNRESTRICTED,
tokens: null,
});
expect(auth.login).toHaveBeenCalledWith(USER.email, 'pw', '203.0.113.10', {
otpCode: undefined,
userAgent: null,
sessionClient: 'app',
});
});
it('갱신은 본문의 리프레시 토큰을 앱 클라이언트로 넘긴다 — 회전이면 tokens, 유예면 tokens: null', async () => {
auth.refresh.mockResolvedValueOnce({
user: USER,
tokens: TOKENS,
state: UNRESTRICTED,
});
await expect(
controller.refresh(
{ refreshToken: 'old.refresh' },
'203.0.113.10',
fakeRequest({ 'user-agent': APP_UA }),
),
).resolves.toEqual({ ...USER, ...UNRESTRICTED, tokens: APP_TOKENS });
expect(auth.refresh).toHaveBeenCalledWith('old.refresh', {
ip: '203.0.113.10',
userAgent: APP_UA,
sessionClient: 'app',
});
auth.refresh.mockResolvedValueOnce({
user: USER,
tokens: null,
state: UNRESTRICTED,
});
await expect(
controller.refresh(
{ refreshToken: 'old.refresh' },
'203.0.113.10',
fakeRequest(),
),
).resolves.toEqual({ ...USER, ...UNRESTRICTED, tokens: null });
});
it('로그아웃은 본문의 리프레시 토큰과 Bearer 액세스 토큰을 서비스에 넘기고 늘 null 이다 — Bearer 가 아니거나 비었으면 넘기지 않는다', async () => {
await expect(
controller.logout(
{ refreshToken: 'old.refresh' },
fakeRequest({ authorization: 'Bearer old.access' }),
),
).resolves.toBeNull();
expect(auth.logoutApp).toHaveBeenLastCalledWith(
'old.refresh',
'old.access',
);
for (const authorization of [undefined, 'Bearer ', 'Basic dXNlcjpwdw==']) {
await controller.logout(
{},
fakeRequest(authorization === undefined ? {} : { authorization }),
);
expect(auth.logoutApp).toHaveBeenLastCalledWith(undefined, undefined);
}
});
});
@@ -0,0 +1,185 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Ip,
Post,
Req,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request } from 'express';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { NoStore } from '@/common/decorators/no-store.decorator';
import { parseBearerAuthorization } from './access-token';
import type { SessionTokens } from './auth-cookies';
import { LOGIN_THROTTLE } from './auth.constants';
import { AuthService } from './auth.service';
import {
AppLoginResultDto,
AppLogoutDto,
AppRefreshDto,
AppRefreshResultDto,
type AppTokensDto,
} from './dto/app-auth.dto';
import { LoginDto } from './dto/login.dto';
/** 서버가 만든 두 토큰 → 앱 응답의 tokens (끝나는 때는 ISO 8601 UTC — 토큰의 exp 와 같다) */
function toAppTokens(tokens: SessionTokens): AppTokensDto {
return {
accessToken: tokens.accessToken,
accessExpiresAt: tokens.accessExpiresAt.toISOString(),
refreshToken: tokens.refreshToken,
refreshExpiresAt: tokens.refreshExpiresAt.toISOString(),
};
}
/**
* 앱(모바일) 전용 토큰 경로 (계약 1절 "앱 로그인") — 세션 규칙은 웹과 같고(AuthService 하나), 토큰을 나르는 길만 다르다:
* 쿠키 대신 응답 본문의 `tokens` 로 주고 본문 · `Authorization: Bearer` 로 받는다. 앱 토큰에는 표시(cli: app)가 있어 웹 경로 · 쿠키로는 받지 않는다.
*
* - 실패 카운터 · 2단계 코드 가드 · 앱 비밀번호 모양 거절 · 엔진 관리자 차단은 웹 로그인과 같은 함수를 지나고, 요청 수 한도는 웹 로그인과 같은 칸을 함께 센다
* - 토큰이 든 응답(로그인 · 갱신)은 Cache-Control: private, no-store
* - 가드가 없다 — 로그인 전 · 액세스 토큰이 끝난 뒤에 부른다. 출처 검사(OriginGuard)는 앱(Origin · Sec-Fetch-Site 없음)을 지금 규칙대로 통과시킨다
* 판단은 모두 AuthService 가 한다. 토큰 · 비밀번호는 로그 · 오류 context 에 남기지 않는다
*/
@ApiTags('Auth')
@Controller('auth/app')
export class AppAuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
@Throttle(LOGIN_THROTTLE)
@HttpCode(HttpStatus.OK)
@NoStore()
@ApiOperation({
summary: '앱 로그인 — 성공하면 두 토큰을 본문(tokens)으로 준다',
description:
'본문 · 응답 칸은 웹 로그인(POST /api/auth/login)과 같고 tokens 가 더해진다 — signedIn 에만 차고 twoFactorRequired 는 null(쿠키는 굽지 않는다). ' +
'2단계 인증 · 앱 비밀번호 거절 · 실패 한도(같은 IP 1분 실패 20회 · 같은 주소 15분 실패 10회 · 코드 실패 같은 주소 15분 5회 · 24시간 20회)는 웹과 같은 함수를 지나고 웹과 함께 센다. ' +
'요청 수 상한(IP 당 1분 60회)도 웹 로그인과 같은 칸이다. 로그인 기록의 방식은 app, 로그인된 기기에는 User-Agent(ARASYSTEM-App/…)로 "ARA SYSTEM 앱"이 보인다. ' +
'응답은 Cache-Control: private, no-store.',
})
@ApiStandardResponse(AppLoginResultDto, {
description:
'로그인 성공(signedIn — tokens) 또는 2단계 인증 코드 필요(twoFactorRequired — tokens 는 null)',
})
@ApiErrorResponse(
'AUTH_004',
'계정 없음·비밀번호 틀림(코드 단계에서도)·앱 비밀번호·사내 도메인이 아닌 주소·메일 엔진 관리자 계정 (구분하지 않는다 — 웹과 같다)',
)
// 같은 400 이라 한 항목에 함께 적는다 (Swagger 는 상태 코드마다 하나만 남긴다)
@ApiErrorResponse(
'AUTH_006',
'AUTH_006 2단계 인증 코드 틀림 · 2분 안에 다시 쓴 코드 / VAL_001 형식 오류 (모두 400)',
)
@ApiErrorResponse(
'REQ_001',
'시도 과다 — 웹 로그인과 같은 실패 한도 · 같은 주소의 로그인이 처리 중 · IP 당 1분 60회 요청(웹 로그인과 함께 센다)',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애')
async login(
@Body() dto: LoginDto,
@Ip() ip: string,
@Req() req: Request,
): Promise<AppLoginResultDto> {
const result = await this.auth.login(dto.email, dto.password, ip, {
otpCode: dto.otpCode,
userAgent: req.get('user-agent') ?? null,
sessionClient: 'app',
});
if (result.status === 'twoFactorRequired') {
return {
status: result.status,
email: null,
name: null,
restriction: null,
twoFactorDeadline: null,
tokens: null,
};
}
return {
status: result.status,
email: result.user.email,
name: result.user.name,
restriction: result.state.restriction,
twoFactorDeadline: result.state.twoFactorDeadline,
tokens: toAppTokens(result.tokens),
};
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@NoStore()
@ApiOperation({
summary:
'앱 토큰 회전 — 본문의 리프레시 토큰으로 두 토큰을 새로 준다 (1회용)',
description:
'웹 갱신(POST /api/auth/refresh)과 같은 세션 규칙 — 이미 쓴 리프레시 토큰이 오면 세션 전체를 끊는다. ' +
'회전 직후 30초 안에 바로 이전 토큰이 다시 오면 새 토큰 없이 200(tokens: null)이고, 앱 세션은 그 세션을 끝낸다 ' +
'(앱은 갱신을 한 번에 하나만 하므로 회전 응답을 잃었거나 토큰이 두 번 쓰였다 — 이어 쓸 토큰이 없다). 응답 칸(restriction · twoFactorDeadline)은 늘 싣는다. ' +
'웹(쿠키)의 리프레시 토큰은 받지 않는다. 빈 글 · 2,048자를 넘는 토큰은 서명을 보기 전에 401 이다. 응답은 Cache-Control: private, no-store.',
})
@ApiStandardResponse(AppRefreshResultDto, {
description:
'회전 성공(tokens) 또는 갱신 유예(tokens: null — 그 앱 세션은 끝났다)',
})
@ApiErrorResponse(
'AUTH_001',
'리프레시 토큰 빈 글·2,048자 넘음·만료·위조·웹 토큰·이미 쓴 토큰(세션 폐기)·로그아웃한 세션·계정 비활성 — 앱은 토큰을 지우고 로그인 화면으로',
)
@ApiErrorResponse(
'VAL_001',
'본문 형식 오류 (refreshToken 이 없거나 글이 아님 · 모르는 칸)',
)
@ApiErrorResponse('MAIL_001', '메일 엔진 장애 — 세션은 그대로 둔다')
@ApiErrorResponse(
'SYS_001',
'서버 쪽 드문 불일치(회전 충돌) — 세션은 그대로, 같은 토큰으로 다시 시도할 수 있다 · ' +
'앱 세션의 유예에서 세션 끊기에 실패해도 500 — 다시 보내면 끊는다',
)
async refresh(
@Body() dto: AppRefreshDto,
@Ip() ip: string,
@Req() req: Request,
): Promise<AppRefreshResultDto> {
const result = await this.auth.refresh(dto.refreshToken, {
ip,
userAgent: req.get('user-agent') ?? null,
sessionClient: 'app',
});
return {
email: result.user.email,
name: result.user.name,
restriction: result.state.restriction,
twoFactorDeadline: result.state.twoFactorDeadline,
tokens: result.tokens ? toAppTokens(result.tokens) : null,
};
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({
summary: '앱 로그아웃 — 서버의 세션을 끝낸다 (본문 모양이 맞으면 늘 200)',
description:
'본문의 리프레시 토큰으로 세션을 찾고, 없거나 · 빈 글 · 2,048자 넘음 · 틀리면 `Authorization: Bearer` 액세스 토큰의 sid 로 끝낸다(만료된 토큰도 서명이 맞으면). ' +
'어느 쪽도 맞지 않아도 200 이다 — 400 은 본문 모양이 틀릴 때뿐이다(refreshToken 이 글이 아님 · 모르는 칸). ' +
'끝낸 세션의 액세스 토큰은 서버가 1시간 기억해 바로 막는다.',
})
@ApiStandardResponse(undefined, { description: '로그아웃 (data: null)' })
@ApiErrorResponse(
'VAL_001',
'본문 형식 오류 (refreshToken 이 글이 아님 · 모르는 칸)',
)
async logout(@Body() dto: AppLogoutDto, @Req() req: Request): Promise<null> {
const bearer = parseBearerAuthorization(req.headers.authorization);
await this.auth.logoutApp(dto.refreshToken, bearer || undefined);
return null;
}
}
@@ -54,7 +54,13 @@ describe('세션 쿠키', () => {
setSessionCookies(
res as unknown as Response,
{ accessToken: 'a.b.c', refreshToken: 'd.e.f', refreshTtlSeconds: 600 },
{
accessToken: 'a.b.c',
refreshToken: 'd.e.f',
refreshTtlSeconds: 600,
accessExpiresAt: new Date('2026-11-06T03:00:00.000Z'),
refreshExpiresAt: new Date('2026-11-06T02:10:00.000Z'),
},
settings,
);
clearSessionCookies(res as unknown as Response, settings);
+4
View File
@@ -21,6 +21,10 @@ export interface SessionTokens {
readonly refreshToken: string;
/** 리프레시 토큰의 남은 수명(초) — 세션 절대 만료가 가까우면 JWT_REFRESH_EXPIRES_IN 보다 짧다 */
readonly refreshTtlSeconds: number;
/** 액세스 토큰이 끝나는 때 — 토큰의 exp 와 같다 (앱 응답의 accessExpiresAt — 계약 1절 "앱 로그인") */
readonly accessExpiresAt: Date;
/** 리프레시 토큰이 끝나는 때 — 토큰의 exp 와 같다 (앱 응답의 refreshExpiresAt) */
readonly refreshExpiresAt: Date;
}
type CookieKind = 'access' | 'refresh';
@@ -1,6 +1,7 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
import type { SessionClient } from '@/common/decorators/current-user.decorator';
/** 메일 주소 최대 길이 (RFC 5321 경로 한도) — mail.constants 의 MAX_EMAIL_LENGTH 와 같다 */
const EMAIL_COLUMN_LENGTH = 254;
@@ -14,6 +15,10 @@ const IP_COLUMN_LENGTH = 64;
const USER_AGENT_COLUMN_LENGTH = 512;
/** 제한 이름 길이 — 'passwordChange' · 'twoFactorSetup' (여유를 둔다) */
const RESTRICTION_COLUMN_LENGTH = 32;
/** 클라이언트 이름 길이 — 'web' · 'app' (여유를 둔다) */
const CLIENT_COLUMN_LENGTH = 16;
/** 칸을 모르는 코드(다른 브랜치 · 옛 행)가 넣은 행은 웹 세션이다 — 마이그레이션의 DB 기본값과 같다 */
const DEFAULT_SESSION_CLIENT: SessionClient = 'web';
/** 세션을 끝낸 사유 — 로그·문의 대응용 */
export type SessionRevokedReason =
@@ -32,7 +37,12 @@ export type SessionRevokedReason =
* 2단계 인증을 거친 적이 없는 세션(twoFactorSetup 제한)인데 그 직원이 2단계를 켰다 — 다른 세션에서 켰을 때 · 갱신에서 계정이 이미 켜짐일 때
* (비밀번호만 아는 다른 사람의 세션일 수 있다 — 계약 6절 "제한된 세션")
*/
| 'two-factor-not-verified';
| 'two-factor-not-verified'
/**
* 앱 세션의 갱신 유예 — 회전 직후 바로 이전 토큰이 다시 왔다 (계약 1절 "앱 로그인").
* 앱은 갱신을 한 번에 하나만 하므로 회전 응답을 잃었거나 훔친 토큰을 누가 먼저 썼다 — 어느 쪽이든 이어 쓸 토큰이 없어 세션을 끝낸다
*/
| 'app-refresh-grace';
/**
* 로그인 세션 (계약 1절 "세션 규칙") — 리프레시 토큰의 1회용 회전과 폐기를 서버에서 판단한다.
@@ -42,6 +52,7 @@ export type SessionRevokedReason =
* 끊은 세션 id 는 서버 메모리가 1시간 기억해 가드가 막는다 (revoked-sessions.ts — 기동 때 이 표의 revoked_at 으로 다시 채운다)
* - IP · User-Agent 는 로그인 · 갱신 때 둔다 — 로그인된 브라우저 목록 (계약 5절). 12단계 전에 연 세션은 둘 다 null
* - 제한(restriction — 13단계)은 로그인 · 갱신 때 정하고 액세스 토큰에도 싣는다. 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 지운다 (계약 6절 "제한된 세션")
* - 클라이언트(client — 앱 1단계)는 로그인 때 정하고 바뀌지 않는다 — 웹(쿠키) · 앱(Bearer · 본문). 갱신은 같은 클라이언트의 토큰으로만 된다 (계약 1절 "앱 로그인")
* - 끝난 세션은 정리 작업이 일정 기간 뒤에 지운다 (auth-session.cleanup.ts)
*/
@Entity({ name: 'auth_sessions' })
@@ -120,4 +131,15 @@ export class AuthSessionEntity {
nullable: true,
})
restriction!: SessionRestriction | null;
/**
* 세션을 연 클라이언트 (앱 1단계 — 계약 1절 "앱 로그인") — web(브라우저 · 쿠키) · app(모바일 앱 · Bearer).
* DB 기본값이 'web' 이라 이 칸을 모르는 코드(다른 브랜치)가 넣은 행 · 앱 1단계 전의 행도 웹 세션이다
*/
@Column({
type: 'varchar',
length: CLIENT_COLUMN_LENGTH,
default: DEFAULT_SESSION_CLIENT,
})
client!: SessionClient;
}
@@ -17,6 +17,7 @@ const OLDER = hashRefreshToken('older.token');
function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
userEmail: ARA,
client: 'web',
tokenHash: CURRENT,
previousTokenHash: PREVIOUS,
rotatedAt: new Date(NOW.getTime() - 5_000),
@@ -27,8 +28,12 @@ function session(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
};
}
const classify = (snapshot: SessionSnapshot | null, hash: string, now = NOW) =>
classifyRefresh(snapshot, { sub: ARA }, hash, now, GRACE_MS);
const classify = (
snapshot: SessionSnapshot | null,
hash: string,
now = NOW,
client: 'web' | 'app' = 'web',
) => classifyRefresh(snapshot, { sub: ARA, client }, hash, now, GRACE_MS);
describe('classifyRefresh', () => {
it('지금 토큰이면 current', () => {
@@ -75,6 +80,18 @@ describe('classifyRefresh', () => {
classify(session({ userEmail: 'doyun.lee@arasystem.test' }), CURRENT),
).toBe('missing');
});
it('토큰과 세션의 클라이언트(웹 · 앱)가 다르면 missing — 재사용으로 보고 세션을 끊지도 않는다 (계약 1절 "앱 로그인")', () => {
expect(classify(session(), CURRENT, NOW, 'app')).toBe('missing');
expect(classify(session(), OLDER, NOW, 'app')).toBe('missing');
expect(classify(session({ client: 'app' }), CURRENT, NOW, 'app')).toBe(
'current',
);
expect(classify(session({ client: 'app' }), PREVIOUS, NOW, 'app')).toBe(
'grace',
);
expect(classify(session({ client: 'app' }), CURRENT)).toBe('missing');
});
});
describe('hashRefreshToken', () => {
@@ -1,8 +1,12 @@
import { createHash, timingSafeEqual } from 'crypto';
import type { SessionClient } from '@/common/decorators/current-user.decorator';
/** 판단에 필요한 세션 값 — 엔티티의 일부 */
export interface SessionSnapshot {
readonly userEmail: string;
/** 세션을 연 클라이언트 — 갱신은 같은 클라이언트의 토큰으로만 된다 (계약 1절 "앱 로그인") */
readonly client: SessionClient;
readonly tokenHash: string;
readonly previousTokenHash: string | null;
readonly rotatedAt: Date | null;
@@ -40,13 +44,19 @@ function sameHash(a: string, b: string): boolean {
export function classifyRefresh(
session: SessionSnapshot | null,
claims: { readonly sub: string },
claims: { readonly sub: string; readonly client: SessionClient },
presentedHash: string,
now: Date,
graceMs: number,
): RefreshState {
// 서명이 맞는데 주인이 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
if (!session || session.userEmail !== claims.sub) return 'missing';
// 서명이 맞는데 주인 · 클라이언트(웹 · 앱)가 다르면 우리가 만든 토큰이 아니다 — 없는 세션과 같게 다룬다
if (
!session ||
session.userEmail !== claims.sub ||
session.client !== claims.client
) {
return 'missing';
}
if (session.revokedAt !== null) return 'revoked';
if (session.expiresAt <= now || session.absoluteExpiresAt <= now) {
return 'expired';
@@ -34,6 +34,7 @@ import { AuthSessionsService } from './auth-sessions.service';
import { AuthSessionPageDto, EndedSessionsDto } from './dto/auth-session.dto';
// 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 경로가 /api/auth 아래인 까닭: 리프레시 쿠키(Path=/api/auth)로 이 요청의 세션을 안다.
// 앱(Bearer)은 쿠키가 없어 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션").
// 판단은 AuthSessionsService 가 한다. 끊은 세션은 바로 막힌다(액세스 토큰의 sid + 서버가 기억한 끊은 세션)
// 13단계 — /api/auth/* 라 제한된 세션도 부른다(계약 6절 "제한된 세션")
@ApiTags('Auth')
@@ -47,9 +48,10 @@ export class AuthSessionsController {
@Get()
@ApiOperation({
summary: '로그인된 브라우저 — 끝나지 않은 웹메일 세션',
summary: '로그인된 브라우저 — 끝나지 않은 웹메일 · 앱 세션',
description:
'지금 세션(리프레시 쿠키가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' +
'지금 세션(웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것)이 맨 앞, 그다음 최근 활동 차례. 마지막 활동은 마지막 갱신(회전) 때 — 없으면 로그인 때. ' +
'client 는 세션을 연 클라이언트(web · app)이고, 앱 세션의 브라우저 이름은 ARA SYSTEM 앱이다(세션 표가 앱일 때만 — User-Agent 가 앱 꼴이어도 웹 세션에는 붙이지 않는다). ' +
'IP · 브라우저는 로그인 · 갱신 때 서버가 둔다(12단계 전에 연 세션은 null). 위치 커서 · 기본 20 · 최대 100.',
})
@ApiStandardResponse(AuthSessionPageDto, { description: '한 쪽' })
@@ -60,7 +62,7 @@ export class AuthSessionsController {
@Req() req: Request,
): Promise<AuthSessionPageDto> {
const page = await this.authSessions.list(
user.email,
user,
readCookie(req, REFRESH_TOKEN_COOKIE),
{ cursor: query.cursor, limit: query.limit },
);
@@ -71,6 +73,7 @@ export class AuthSessionsController {
lastActiveAt: session.lastActiveAt.toISOString(),
ip: session.ip,
device: session.device,
client: session.client,
isCurrent: session.isCurrent,
})),
pageInfo: page.pageInfo,
@@ -94,7 +97,7 @@ export class AuthSessionsController {
@Req() req: Request,
): Promise<null> {
await this.authSessions.end(
user.email,
user,
id,
readCookie(req, REFRESH_TOKEN_COOKIE),
);
@@ -106,7 +109,8 @@ export class AuthSessionsController {
@ApiOperation({
summary: '다른 곳 모두 로그아웃 — 이 요청의 세션만 남긴다',
description:
'리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.',
'리프레시 쿠키가 없거나 남의 것이면 모두 끊는다(비밀번호 바꾸기와 같다). 앱(Bearer)이 부르면 그 앱 세션(액세스 토큰의 sid)을 남긴다. ' +
'메일 프로그램(IMAP · SMTP)은 끊기지 않는다 — 앱 비밀번호를 지워 끊는다.',
})
@ApiStandardResponse(EndedSessionsDto, { description: '끊은 세션 수' })
async endOthers(
@@ -114,7 +118,7 @@ export class AuthSessionsController {
@Req() req: Request,
): Promise<EndedSessionsDto> {
const ended = await this.authSessions.endOthers(
user.email,
user,
readCookie(req, REFRESH_TOKEN_COOKIE),
);
return { ended };
@@ -13,6 +13,9 @@ const OTHER = '22222222-2222-4222-8222-222222222222';
const OLDER = '33333333-3333-4333-8333-333333333333';
const CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
/** 웹(쿠키)으로 들어온 직원 — 가드가 넣은 값 */
const USER = { email: ARA, name: '김아라', sessionId: CURRENT };
const session = (
id: string,
@@ -32,6 +35,7 @@ const session = (
ip: '211.186.95.252',
userAgent: CHROME,
restriction: null,
client: 'web',
...extra,
});
@@ -48,7 +52,7 @@ async function failureOf(
}
describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () => {
let auth: { currentSessionId: jest.Mock };
let auth: { requestSessionId: jest.Mock };
let sessions: {
listAliveOf: jest.Mock;
revokeAliveOf: jest.Mock;
@@ -57,7 +61,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
let service: AuthSessionsService;
beforeEach(() => {
auth = { currentSessionId: jest.fn().mockResolvedValue(CURRENT) };
auth = { requestSessionId: jest.fn().mockResolvedValue(CURRENT) };
sessions = {
listAliveOf: jest.fn().mockResolvedValue([
session(OLDER, '2026-10-30T01:00:00.000Z', {
@@ -80,9 +84,9 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
afterEach(() => jest.restoreAllMocks());
it('지금 세션이 맨 앞, 그다음 최근 활동 차례 — 브라우저 · 운영체제는 짧은 이름, 12단계 전의 세션은 null', async () => {
const page = await service.list(ARA, 'refresh.cookie', { limit: 20 });
const page = await service.list(USER, 'refresh.cookie', { limit: 20 });
expect(auth.currentSessionId).toHaveBeenCalledWith('refresh.cookie', ARA);
expect(auth.requestSessionId).toHaveBeenCalledWith(USER, 'refresh.cookie');
expect(page.items.map(({ id, isCurrent }) => [id, isCurrent])).toEqual([
[CURRENT, true],
[OTHER, false],
@@ -92,6 +96,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
lastActiveAt: new Date('2026-10-31T02:00:00.000Z'),
ip: '211.186.95.252',
device: { browser: 'Chrome', os: 'Windows' },
client: 'web',
});
expect(page.items[2]).toMatchObject({ ip: null, device: null });
expect(page.pageInfo).toEqual({
@@ -102,9 +107,9 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
});
it('위치 커서로 나눠 준다 — 다른 목록의 커서는 VAL_001', async () => {
const first = await service.list(ARA, undefined, { limit: 2 });
const first = await service.list(USER, undefined, { limit: 2 });
expect(first.pageInfo.hasNext).toBe(true);
const second = await service.list(ARA, undefined, {
const second = await service.list(USER, undefined, {
limit: 2,
cursor: first.pageInfo.nextCursor ?? undefined,
});
@@ -113,7 +118,7 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
expect(
(
await failureOf(
service.list(ARA, undefined, {
service.list(USER, undefined, {
limit: 2,
cursor: encodeOffsetCursor(1, 'contacts'),
}),
@@ -123,13 +128,13 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
});
it('리프레시 쿠키가 없거나 남의 것이면 isCurrent 는 모두 false', async () => {
auth.currentSessionId.mockResolvedValue(null);
const page = await service.list(ARA, undefined, { limit: 20 });
auth.requestSessionId.mockResolvedValue(null);
const page = await service.list(USER, undefined, { limit: 20 });
expect(page.items.every((item) => !item.isCurrent)).toBe(true);
});
it('하나 끊기 — 지금 세션은 400 VAL_001, 모양이 아닌 id · 남의 것 · 없는 것 · 끝난 것은 404 RES_001', async () => {
await expect(service.end(ARA, OTHER, 'cookie')).resolves.toBeUndefined();
await expect(service.end(USER, OTHER, 'cookie')).resolves.toBeUndefined();
expect(sessions.revokeAliveOf).toHaveBeenCalledWith(
OTHER,
ARA,
@@ -137,20 +142,20 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
expect.any(Date),
);
expect((await failureOf(service.end(ARA, CURRENT, 'cookie')))?.code).toBe(
expect((await failureOf(service.end(USER, CURRENT, 'cookie')))?.code).toBe(
'VAL_001',
);
expect(
(await failureOf(service.end(ARA, 'not-a-uuid', 'cookie')))?.code,
(await failureOf(service.end(USER, 'not-a-uuid', 'cookie')))?.code,
).toBe('RES_001');
sessions.revokeAliveOf.mockResolvedValue(false);
expect((await failureOf(service.end(ARA, OLDER, 'cookie')))?.code).toBe(
expect((await failureOf(service.end(USER, OLDER, 'cookie')))?.code).toBe(
'RES_001',
);
});
it('다른 곳 모두 끊기 — 지금 세션만 남긴다, 지금 세션을 모르면 모두', async () => {
await expect(service.endOthers(ARA, 'cookie')).resolves.toBe(2);
await expect(service.endOthers(USER, 'cookie')).resolves.toBe(2);
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
ARA,
'ended-by-user',
@@ -158,8 +163,8 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
CURRENT,
);
auth.currentSessionId.mockResolvedValue(null);
await service.endOthers(ARA, undefined);
auth.requestSessionId.mockResolvedValue(null);
await service.endOthers(USER, undefined);
expect(sessions.revokeAllOf).toHaveBeenLastCalledWith(
ARA,
'ended-by-user',
@@ -167,4 +172,70 @@ describe('AuthSessionsService (로그인된 브라우저 — 계약 5절)', () =
null,
);
});
it('앱(Bearer)이 부르면 그 앱 세션(액세스 토큰의 sid)이 지금 세션이다 — 맨 앞 · isCurrent, 기기는 ARA SYSTEM 앱 · Android (계약 1절 "앱 로그인")', async () => {
const appUser = { ...USER, sessionId: OTHER, client: 'app' as const };
auth.requestSessionId.mockResolvedValue(OTHER);
sessions.listAliveOf.mockResolvedValue([
session(CURRENT, '2026-10-31T05:00:00.000Z'),
session(OTHER, '2026-10-30T01:00:00.000Z', {
userAgent: APP_UA,
client: 'app',
}),
]);
const page = await service.list(appUser, undefined, { limit: 20 });
expect(auth.requestSessionId).toHaveBeenCalledWith(appUser, undefined);
expect(page.items[0]).toMatchObject({
id: OTHER,
isCurrent: true,
client: 'app',
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
});
expect(page.items[1]).toMatchObject({
id: CURRENT,
isCurrent: false,
client: 'web',
});
});
it('앱 이름은 세션 표가 앱일 때만 — 웹 세션이 앱 꼴 User-Agent 를 보내도 브라우저는 null, 앱 세션은 User-Agent 가 없어도 ARA SYSTEM 앱', async () => {
sessions.listAliveOf.mockResolvedValue([
session(CURRENT, '2026-10-31T05:00:00.000Z', { userAgent: APP_UA }),
session(OTHER, '2026-10-30T01:00:00.000Z', {
userAgent: null,
client: 'app',
}),
]);
const page = await service.list(USER, 'refresh.cookie', { limit: 20 });
expect(page.items[0]).toMatchObject({
id: CURRENT,
client: 'web',
device: { browser: null, os: 'Android' },
});
expect(page.items[1]).toMatchObject({
id: OTHER,
client: 'app',
device: { browser: 'ARA SYSTEM 앱', os: null },
});
});
it('앱이 부른 다른 곳 모두 끊기는 그 앱 세션을 남긴다 · 하나 끊기에서 그 앱 세션은 VAL_001', async () => {
const appUser = { ...USER, sessionId: OTHER, client: 'app' as const };
auth.requestSessionId.mockResolvedValue(OTHER);
await service.endOthers(appUser, undefined);
expect(sessions.revokeAllOf).toHaveBeenLastCalledWith(
ARA,
'ended-by-user',
expect.any(Date),
OTHER,
);
expect(
(await failureOf(service.end(appUser, OTHER, undefined)))?.code,
).toBe('VAL_001');
});
});
@@ -1,5 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import type {
AuthenticatedUser,
SessionClient,
} from '@/common/decorators/current-user.decorator';
import {
type PaginatedResult,
toPaginatedResult,
@@ -10,17 +14,19 @@ import {
decodeOffsetCursor,
encodeOffsetCursor,
} from '@/shared/utils/offset-cursor';
import { describeUserAgent, type DeviceNames } from '@/shared/utils/user-agent';
import {
describeAppUserAgent,
describeUserAgent,
type DeviceNames,
} from '@/shared/utils/user-agent';
import type { AuthSessionEntity } from './auth-session.entity';
import { AuthSessionRepository } from './auth-session.repository';
import { SESSION_ID_PATTERN } from './auth.constants';
import { AuthService } from './auth.service';
import { normalizeEmail } from './employee-address';
/** 커서의 범위 — 이 목록의 커서만 받는다 */
const SESSION_LIST_SCOPE = 'auth-sessions';
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 없는 세션과 같다) */
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** 로그인된 브라우저 하나 (계약 5절 "로그인된 브라우저") */
export interface AuthSessionView {
@@ -30,7 +36,9 @@ export interface AuthSessionView {
readonly lastActiveAt: Date;
readonly ip: string | null;
readonly device: DeviceNames | null;
/** 이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 */
/** 세션을 연 클라이언트 — 웹(브라우저) · 앱 (세션 표의 client — 계약 1절 "앱 로그인") */
readonly client: SessionClient;
/** 이 요청의 세션인가 — 웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것 */
readonly isCurrent: boolean;
}
@@ -39,9 +47,20 @@ function lastActiveAt(session: AuthSessionEntity): Date {
}
/**
* 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 세션의 목록 · 끊기.
* 세션의 기기 이름 — 앱 이름(ARA SYSTEM 앱)은 세션 표가 앱이라고 할 때만 붙인다.
* User-Agent 는 꾸밀 수 있으므로 웹 세션이 앱 꼴을 보내도 앱처럼 보이지 않게 (브라우저 null)
*/
function deviceOf(session: AuthSessionEntity): DeviceNames | null {
return session.client === 'app'
? describeAppUserAgent(session.userAgent)
: describeUserAgent(session.userAgent);
}
/**
* 로그인된 브라우저 (계약 5절 "로그인된 브라우저") — 끝나지 않은 웹메일 · 앱 세션의 목록 · 끊기.
*
* - 지금 세션은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 안다 — 없거나 남의 것이면 isCurrent 는 모두 false,
* - 지금 세션은 웹은 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다), 앱(Bearer)은 액세스 토큰의 sid 로 안다
* (AuthService.requestSessionId — 계약 1절 "앱 로그인 › 지금 세션"). 모르면 isCurrent 는 모두 false,
* "다른 곳 모두 끊기"는 모두 끊는다(비밀번호 바꾸기와 같다)
* - 끊은 세션은 바로 막힌다 — 세션 표의 끊기가 끊은 세션 기억에 넣고 가드가 그 액세스 토큰을 막는다 (auth-session.repository.ts)
* - 목록은 지금 세션이 맨 앞, 그다음 최근 활동 차례 — 위치 커서(shared/utils/offset-cursor.ts), 기본 20 · 최대 100
@@ -58,15 +77,15 @@ export class AuthSessionsService {
/** GET /api/auth/sessions */
async list(
user: string,
user: AuthenticatedUser,
refreshToken: string | undefined,
query: { readonly cursor?: string; readonly limit: number },
now: Date = new Date(),
): Promise<PaginatedResult<AuthSessionView>> {
const address = normalizeEmail(user);
const address = normalizeEmail(user.email);
const start = this.offsetOf(query.cursor);
const [currentId, rows] = await Promise.all([
this.auth.currentSessionId(refreshToken, address),
this.auth.requestSessionId(user, refreshToken),
this.sessions.listAliveOf(address, now),
]);
@@ -87,7 +106,8 @@ export class AuthSessionsService {
createdAt: session.createdAt,
lastActiveAt: lastActiveAt(session),
ip: session.ip,
device: describeUserAgent(session.userAgent),
device: deviceOf(session),
client: session.client,
isCurrent: session.id === currentId,
})),
{
@@ -104,13 +124,13 @@ export class AuthSessionsService {
* DELETE /api/auth/sessions/{id} — 지금 세션은 400 VAL_001 (로그아웃을 쓴다), 남의 것 · 없는 것 · 끝난 것은 404 RES_001
*/
async end(
user: string,
user: AuthenticatedUser,
sessionId: string,
refreshToken: string | undefined,
): Promise<void> {
const address = normalizeEmail(user);
const address = normalizeEmail(user.email);
if (!SESSION_ID_PATTERN.test(sessionId)) throw this.sessionNotFound();
const currentId = await this.auth.currentSessionId(refreshToken, address);
const currentId = await this.auth.requestSessionId(user, refreshToken);
if (sessionId.toLowerCase() === currentId?.toLowerCase()) {
throw new BusinessException('VAL_001', {
context: { reason: 'cannot-end-current-session', sid: sessionId },
@@ -128,13 +148,13 @@ export class AuthSessionsService {
);
}
/** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 */
/** DELETE /api/auth/sessions — 이 요청의 세션만 남기고 모두 끊는다 → 끊은 수 (앱이 부르면 그 앱 세션을 남긴다) */
async endOthers(
user: string,
user: AuthenticatedUser,
refreshToken: string | undefined,
): Promise<number> {
const address = normalizeEmail(user);
const currentId = await this.auth.currentSessionId(refreshToken, address);
const address = normalizeEmail(user.email);
const currentId = await this.auth.requestSessionId(user, refreshToken);
const ended = await this.sessions.revokeAllOf(
address,
'ended-by-user',
@@ -3,9 +3,13 @@ import type { SessionRestriction } from '@/common/decorators/allow-restricted-se
/**
* JWT 페이로드.
* `typ` 으로 액세스/리프레시를 구분한다 — 리프레시 토큰을 액세스 쿠키에 넣어도 통하지 않고, 그 반대도 마찬가지다.
* `cli` 로 웹 · 앱 토큰을 구분한다 — 앱 토큰은 Bearer · 본문으로만, 웹 토큰은 쿠키로만 받는다 (계약 1절 "앱 로그인")
*/
export type AuthTokenType = 'access' | 'refresh';
/** 앱 토큰의 표시 (`cli`) — 앱 로그인 · 앱 갱신이 준 두 토큰에만 싣는다. 웹(쿠키) 토큰에는 없다 */
export const APP_TOKEN_CLIENT = 'app';
export interface AuthTokenPayload {
/** 직원 메일 주소 */
readonly sub: string;
@@ -18,6 +22,11 @@ export interface AuthTokenPayload {
* 12단계 전에 발급한 액세스 토큰에는 없다 — 만료까지 지금처럼 쓰인다
*/
readonly sid?: string;
/**
* 앱 토큰의 표시 (계약 1절 "앱 로그인") — 액세스 · 리프레시 둘 다. 있으면 앱(Bearer · 본문), 없으면 웹(쿠키) 토큰이다.
* 가드는 Bearer 로 온 토큰에 이 표시가 없거나 쿠키로 온 토큰에 있으면 막는다(서로 바꿔 쓰지 못하게). 모르는 값이면 막는다
*/
readonly cli?: typeof APP_TOKEN_CLIENT;
/**
* 제한된 세션의 제한 (13단계 — 계약 6절 "제한된 세션") — 액세스 토큰에만, 제한이 있을 때만 싣는다.
* 가드가 허용한 핸들러 밖이면 403 AUTH_010. 세션 표의 restriction 과 같은 값이다(로그인 · 갱신 때 정한다)
@@ -46,6 +46,13 @@ export const ENDED_SESSION_RETENTION_MS = 7 * DAY_SECONDS * 1000;
/** 서명 알고리즘 — 검증 때도 이것만 받는다 (alg 바꿔치기 방지) */
export const JWT_ALGORITHM = 'HS256';
/**
* 세션 id 모양 (auth_sessions.id — UUID) — DB 의 uuid 열에 넣기 전 · 토큰의 sid 를 믿기 전에 본다
* (모양이 틀리면 DB 오류가 SYS_001 로 새어 나가거나 없는 세션을 지금 세션으로 알게 된다)
*/
export const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* 로그인 **실패** 한도 (계약 1절) — 성공한 로그인은 세지 않는다.
* 사무실처럼 여러 직원이 한 IP 를 쓰면 성공까지 세는 빡빡한 한도는 정상 로그인까지 막기 때문이다.
@@ -85,6 +92,30 @@ export const REVOKED_SESSION_MEMORY_MS = ACCESS_TTL_MAX_SECONDS * 1000;
export const LOGIN_REQUEST_LIMIT = 60;
export const LOGIN_REQUEST_WINDOW_MS = 60_000;
/**
* 로그인 요청 수 상한의 칸 이름 — 웹 로그인과 앱 로그인(계약 1절 "앱 로그인")이 같은 IP 의 칸 **하나**를 함께 쓴다.
* throttler 는 라우트마다 따로 세므로 그대로면 앱 경로가 한도를 하나 더 얻는다 — 키를 같은 이름으로 만들어 막는다
*/
export const LOGIN_THROTTLE_KEY = 'auth-login';
/**
* 웹 · 앱 로그인의 `@Throttle` 설정 (IP 당 1분 60회 — 함께 센다). tracker 는 전역 가드의 clientIpKey (IPv6 /64)
*/
export const LOGIN_THROTTLE = {
default: {
ttl: LOGIN_REQUEST_WINDOW_MS,
limit: LOGIN_REQUEST_LIMIT,
generateKey: (_context: unknown, tracker: string, name: string): string =>
`${LOGIN_THROTTLE_KEY}:${name}:${tracker}`,
},
};
/**
* 리프레시 토큰의 길이 상한 — 넘으면 서명을 보기 전에 틀린 토큰으로 본다(갱신 401 AUTH_001 · 앱 로그아웃은 Bearer 의 sid 로 넘어감 — 계약 1절 "앱 로그인").
* 서버가 만든 토큰은 500자 안팎이다 — 아주 긴 값으로 서명 확인을 붙잡지 못하게 넉넉히 둔다
*/
export const MAX_REFRESH_TOKEN_LENGTH = 2_048;
/** 비밀번호 최대 길이 — 아주 긴 값으로 메일 엔진의 해시 계산을 오래 붙잡지 못하게 한다 */
export const MAX_PASSWORD_LENGTH = 256;
+9 -11
View File
@@ -29,11 +29,7 @@ import { BusinessException } from '@/common/errors/business.exception';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { clearSessionCookies, setSessionCookies } from './auth-cookies';
import {
LOGIN_REQUEST_LIMIT,
LOGIN_REQUEST_WINDOW_MS,
REFRESH_TOKEN_COOKIE,
} from './auth.constants';
import { LOGIN_THROTTLE, REFRESH_TOKEN_COOKIE } from './auth.constants';
import { AuthService } from './auth.service';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { AuthUserDto } from './dto/auth-user.dto';
@@ -42,8 +38,9 @@ import { LoginResultDto } from './dto/login-result.dto';
import { LoginDto } from './dto/login.dto';
/**
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다.
* 직원 인증 — 쿠키 기반 (계약 1절). 토큰은 응답 본문에 없다. 앱(모바일)의 토큰 경로는 app-auth.controller.ts (`/api/auth/app/*`).
* 쿠키 굽기·지우기는 HTTP 응답 처리라 여기서 하고, 판단은 모두 AuthService 가 한다.
* me · 비밀번호 바꾸기는 앱도 Bearer 로 부른다(가드가 두 길을 받는다 — 앱은 "지금 세션"을 액세스 토큰의 sid 로 안다).
* 13단계: `/api/auth/*` 는 제한된 세션(비밀번호 바꾸기 · 2단계 켜기 전)도 부른다 — 로그인 · me · 갱신의 응답에 restriction · twoFactorDeadline 이 있다
*/
@ApiTags('Auth')
@@ -56,11 +53,10 @@ export class AuthController {
) {}
// 무차별 대입은 **실패** 횟수로 막는다 (LoginAttemptService: IP 1분 20회 · 주소 15분 10회 — 엔진에 묻기 전에 차지).
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회)
// 요청 수 상한은 그와 별개로 넉넉하게 — 사무실 NAT 에서 정상 로그인으로는 닿지 않는 값 (IP 당 1분 60회).
// 앱 로그인(app-auth.controller.ts)과 같은 칸을 함께 센다 — 앱 경로로 한도를 비켜 가지 못하게
@Post('login')
@Throttle({
default: { ttl: LOGIN_REQUEST_WINDOW_MS, limit: LOGIN_REQUEST_LIMIT },
})
@Throttle(LOGIN_THROTTLE)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: '로그인 — 성공하면 access_token·refresh_token 쿠키를 굽는다',
@@ -189,7 +185,8 @@ export class AuthController {
/**
* 비밀번호 바꾸기 — 판단은 모두 AuthService 가 한다. 리프레시 쿠키(Path=/api/auth 라 이 경로에도 온다)로 이 세션을 알아
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다. 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
* 그 세션만 남기고 그 직원의 다른 세션을 끊는다 — 앱(Bearer)은 쿠키가 없어 액세스 토큰의 sid 로 안다(계약 1절 "앱 로그인").
* 쿠키는 굽지도 지우지도 않는다 (이 세션은 그대로 이어 쓴다).
* 요청 수 상한은 전역 기본값 — 무차별 대입은 로그인과 같은 실패 횟수 한도가 막는다.
*/
@Put('password')
@@ -201,6 +198,7 @@ export class AuthController {
description:
'메일 엔진에 그 직원 본인의 자격(지금 비밀번호)으로 보낸다 — 게이트웨이 대리 접근·관리자 권한을 쓰지 않는다. ' +
'바뀌면 이 요청의 세션만 남기고 그 직원의 다른 로그인 세션을 모두 끊는다 (휴대폰·PC 메일 프로그램은 새 비밀번호를 다시 넣어야 한다). ' +
'이 요청의 세션은 웹은 리프레시 쿠키, 앱(Bearer)은 액세스 토큰의 sid 로 안다. ' +
'지금 비밀번호 틀림은 로그인 실패와 같은 한도(같은 IP 1분 20회 · 같은 주소 15분 10회)로 함께 센다. ' +
'지금 비밀번호의 길이 한도는 로그인 비밀번호와 같다. ' +
'12단계: 2단계 인증을 켠 직원은 otpCode 가 필수다(엔진이 본인 자격을 받지 않아 대리 접근으로 코드와 함께 보낸다 — 2단계 인증은 그대로 남는다). ' +
+3 -1
View File
@@ -6,6 +6,7 @@ import { PassportModule } from '@nestjs/passport';
import { AccountSecurityModule } from '@/modules/account-security/account-security.module';
import { MailModule } from '@/modules/mail/mail.module';
import { MailDirectoryModule } from '@/modules/mail-directory/mail-directory.module';
import { AppAuthController } from './app-auth.controller';
import { AuthSessionCleanup } from './auth-session.cleanup';
import { AuthSessionModule } from './auth-session.module';
import { AuthSessionsController } from './auth-sessions.controller';
@@ -18,6 +19,7 @@ import { LoginAttemptModule } from './login-attempt.module';
/**
* 직원 인증 모듈 — 직원 계정 = 메일 계정 (자격 확인은 MailModule 의 포트로).
* 웹은 쿠키(AuthController), 앱(모바일)은 토큰 본문 · Bearer(AppAuthController — 계약 1절 "앱 로그인") — 세션 규칙은 AuthService 하나다.
*
* 서명 키는 모듈 등록 값이 아니라 AUTH_SETTINGS 하나에서 읽는다 —
* 임시 키(비운영, JWT_SECRET 없음)가 서명과 검증(JwtStrategy)에서 어긋나지 않게 하기 위해서다.
@@ -39,7 +41,7 @@ import { LoginAttemptModule } from './login-attempt.module';
// 2단계 인증 코드의 한도 · 다시 쓰기 막기 · 바꾸기 잠금, 로그인 기록, 비밀번호를 바꾸면 앱 비밀번호 지우기
AccountSecurityModule,
],
controllers: [AuthController, AuthSessionsController],
controllers: [AuthController, AppAuthController, AuthSessionsController],
providers: [
{
provide: AUTH_SETTINGS,
+384 -13
View File
@@ -25,6 +25,7 @@ import type { AuthTokenPayload } from './auth-token.types';
import {
LOGIN_ADDRESS_FAILURE_LIMIT,
LOGIN_IP_FAILURE_LIMIT,
MAX_REFRESH_TOKEN_LENGTH,
REFRESH_REUSE_GRACE_MS,
} from './auth.constants';
import { AuthService } from './auth.service';
@@ -252,7 +253,7 @@ describe('AuthService', () => {
let directory: { recordLogin: jest.Mock };
let codes: TwoFactorCodeGuard;
let twoFactorLocks: TwoFactorLocks;
let loginEvents: { recordWebmail: jest.Mock };
let loginEvents: { recordSignIn: jest.Mock };
let appPasswords: { removeAllAfterPasswordChange: jest.Mock };
/** 2단계 인증 필수 정책 대역 (13단계) — 처음은 제한 · 유예 없음 */
let twoFactorPolicy: {
@@ -319,7 +320,7 @@ describe('AuthService', () => {
directory = { recordLogin: jest.fn().mockResolvedValue(undefined) };
codes = new TwoFactorCodeGuard(attempts);
twoFactorLocks = new TwoFactorLocks();
loginEvents = { recordWebmail: jest.fn().mockResolvedValue(undefined) };
loginEvents = { recordSignIn: jest.fn().mockResolvedValue(undefined) };
appPasswords = {
removeAllAfterPasswordChange: jest.fn().mockResolvedValue(0),
};
@@ -1249,7 +1250,7 @@ describe('AuthService', () => {
expect(sessions.create).not.toHaveBeenCalled();
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
// 남기는 것은 판정이 끝난 시도뿐이다 (성공 · AUTH_004 · AUTH_006)
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
expect(loginEvents.recordSignIn).not.toHaveBeenCalled();
// 차지한 실패를 돌려줬다 — 주소 한도보다 많이 해도 막히지 않는다
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT + 2; i += 1) {
await expect(
@@ -1272,7 +1273,8 @@ describe('AuthService', () => {
CODE,
);
expect(mail.getProfile).toHaveBeenCalledWith(ARA, { fresh: true });
expect(loginEvents.recordWebmail).toHaveBeenCalledWith({
expect(loginEvents.recordSignIn).toHaveBeenCalledWith({
method: 'webmail',
email: ARA,
isSuccess: true,
isTwoFactor: true,
@@ -1298,7 +1300,7 @@ describe('AuthService', () => {
expect(failure?.code).toBe('AUTH_006');
expect(failure?.getStatus()).toBe(400);
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
expect.objectContaining({
isSuccess: false,
failureReason: 'wrongCode',
@@ -1419,7 +1421,7 @@ describe('AuthService', () => {
expect(failure?.code).toBe('AUTH_004');
}
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
expect(loginEvents.recordWebmail).not.toHaveBeenCalled();
expect(loginEvents.recordSignIn).not.toHaveBeenCalled();
expect(
(
await failureOf(
@@ -1457,7 +1459,7 @@ describe('AuthService', () => {
)?.code,
).toBe('AUTH_006');
}
expect(loginEvents.recordWebmail).toHaveBeenLastCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
expect.objectContaining({ email: ARA, failureReason: 'wrongCode' }),
);
@@ -1492,7 +1494,7 @@ describe('AuthService', () => {
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: CODE }),
).resolves.toMatchObject({ status: 'signedIn' });
expect(mail.verifyTwoFactorLogin).not.toHaveBeenCalled();
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({ isSuccess: true, isTwoFactor: false }),
);
});
@@ -1505,7 +1507,7 @@ describe('AuthService', () => {
expect(failure?.code).toBe('AUTH_004');
expect(failure?.context).toMatchObject({ reason: 'app-password-shape' });
expect(mail.verifyCredentials).not.toHaveBeenCalled();
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({
isSuccess: false,
failureReason: 'wrongPassword',
@@ -1523,13 +1525,13 @@ describe('AuthService', () => {
it('틀린 비밀번호는 기록에 남긴다 · 사내 도메인 밖 · 막은 계정 · 한도로 막은 요청은 남기지 않는다', async () => {
mail.verifyCredentials.mockResolvedValue(INVALID);
await failureOf(service.login(ARA, 'wrong', CLIENT_IP));
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
expect(loginEvents.recordSignIn).toHaveBeenCalledTimes(1);
await failureOf(service.login('ara.kim@gmail.com', 'wrong', CLIENT_IP));
await failureOf(
service.login('admin@arasystem.test', 'wrong', CLIENT_IP),
);
expect(loginEvents.recordWebmail).toHaveBeenCalledTimes(1);
expect(loginEvents.recordSignIn).toHaveBeenCalledTimes(1);
});
it('액세스 토큰에도 세션 id(sid)를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 막는다', async () => {
@@ -1805,7 +1807,7 @@ describe('AuthService', () => {
// 만료된 계정의 이름(@ 앞)으로 사내 목록을 덮지 않는다
expect(directory.recordLogin).not.toHaveBeenCalled();
// 성공한 로그인이다 — 실패로 세지 않고 기록에 성공으로 남는다
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({ email: ARA, isSuccess: true }),
);
});
@@ -2069,7 +2071,7 @@ describe('AuthService', () => {
service.login(ARA, PASSWORD, CLIENT_IP, { otpCode: '123456' }),
).rejects.toMatchObject({ code: 'AUTH_004' });
// 틀린 비밀번호와 같은 기록
expect(loginEvents.recordWebmail).toHaveBeenCalledWith(
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({
email: ARA,
isSuccess: false,
@@ -2160,4 +2162,373 @@ describe('AuthService', () => {
expect(logs.join('\n')).toContain('세션의 제한을 바꾸지 못했습니다');
});
});
describe('앱 로그인 (앱 전용 토큰 경로 — 계약 1절 "앱 로그인")', () => {
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
const APP = { sessionClient: 'app' as const };
/** 앱으로 로그인해 받은 두 토큰 */
const appLogin = async (email = ARA, ip = CLIENT_IP) => {
const result = await service.login(email, PASSWORD, ip, {
...APP,
userAgent: APP_UA,
});
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
return result.tokens;
};
const refreshApp = (token: string) =>
service.refresh(token, { ip: CLIENT_IP, ...APP });
it('같은 로그인 함수로 앱 세션을 연다 — 세션 표 client app, 두 토큰에 표시 cli: app, 끝나는 때는 토큰의 exp 와 같다', async () => {
const tokens = await appLogin();
const row = sessions.only();
expect(row.client).toBe('app');
expect(row.userAgent).toBe(APP_UA);
const access = decode(tokens.accessToken);
const refresh = decode(tokens.refreshToken);
expect(access).toMatchObject({ typ: 'access', cli: 'app', sid: row.id });
expect(refresh).toMatchObject({
typ: 'refresh',
cli: 'app',
sid: row.id,
});
expect(tokens.accessExpiresAt.getTime()).toBe(access.exp * 1000);
expect(tokens.refreshExpiresAt.getTime()).toBe(refresh.exp * 1000);
expect(access.exp - access.iat).toBe(settings.accessTtlSeconds);
expect(row.tokenHash).toBe(hashRefreshToken(tokens.refreshToken));
// 로그인 기록의 방식은 app, 로그는 가린 주소 + (앱)
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({
method: 'app',
email: ARA,
isSuccess: true,
userAgent: APP_UA,
}),
);
expect(logs.join('\n')).toMatch(/로그인: a\*\*\*@arasystem\.test \(앱\)/);
});
it('웹 로그인은 지금 그대로 — 표시가 없고 세션 표 client web', async () => {
const result = await service.login(ARA, PASSWORD, CLIENT_IP);
if (result.status !== 'signedIn') throw new Error('로그인해야 한다');
expect(sessions.only().client).toBe('web');
expect(decode(result.tokens.accessToken)).not.toHaveProperty('cli');
expect(decode(result.tokens.refreshToken)).not.toHaveProperty('cli');
expect(loginEvents.recordSignIn).toHaveBeenCalledWith(
expect.objectContaining({ method: 'webmail' }),
);
});
it('실패도 방식 app 으로 남기고, 실패 카운터는 웹과 함께 센다 — 주소 15분 10회를 웹 5 · 앱 5 로 채우면 둘 다 REQ_001', async () => {
mail.verifyCredentials.mockResolvedValue(INVALID);
for (let i = 0; i < LOGIN_ADDRESS_FAILURE_LIMIT / 2; i += 1) {
expect(
(await failureOf(service.login(ARA, 'wrong', `198.51.100.${i}`)))
?.code,
).toBe('AUTH_004');
expect(
(await failureOf(service.login(ARA, 'wrong', `198.51.101.${i}`, APP)))
?.code,
).toBe('AUTH_004');
}
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
expect.objectContaining({
method: 'app',
isSuccess: false,
failureReason: 'wrongPassword',
}),
);
mail.verifyCredentials.mockResolvedValue(valid(ARA));
expect(
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.30', APP)))
?.code,
).toBe('REQ_001');
expect(
(await failureOf(service.login(ARA, PASSWORD, '192.0.2.31')))?.code,
).toBe('REQ_001');
});
it('앱 비밀번호 모양 · 막는 계정은 앱 경로에서도 엔진에 묻지 않고 AUTH_004', async () => {
const appPassword = `appx${'a'.repeat(36)}`;
for (const [email, password] of [
[ARA, appPassword],
['admin@arasystem.test', PASSWORD],
] as const) {
expect(
(await failureOf(service.login(email, password, CLIENT_IP, APP)))
?.code,
).toBe('AUTH_004');
}
expect(mail.verifyCredentials).not.toHaveBeenCalled();
});
it('2단계 인증을 켠 직원은 코드 단계를 거친다 — 코드 전에는 토큰 없음(세션 없음), 코드 뒤에 앱 세션', async () => {
mail.verifyCredentials.mockResolvedValue(TWO_FACTOR);
const first = await service.login(ARA, PASSWORD, CLIENT_IP, APP);
expect(first).toEqual({
status: 'twoFactorRequired',
user: null,
tokens: null,
});
expect(sessions.rows.size).toBe(0);
const second = await service.login(ARA, PASSWORD, CLIENT_IP, {
...APP,
otpCode: '123456',
});
expect(second.status).toBe('signedIn');
expect(sessions.only().client).toBe('app');
expect(loginEvents.recordSignIn).toHaveBeenLastCalledWith(
expect.objectContaining({ method: 'app', isTwoFactor: true }),
);
});
it('앱 갱신은 1회용 회전 — 새 두 토큰에도 표시가 있고, 이미 쓴 토큰(두 번 지난 것)은 세션 전체를 끊는다', async () => {
const first = await appLogin();
const rotated = await refreshApp(first.refreshToken);
if (!rotated.tokens) throw new Error('회전해야 한다');
expect(decode(rotated.tokens.accessToken).cli).toBe('app');
expect(decode(rotated.tokens.refreshToken).cli).toBe('app');
expect(rotated.tokens.refreshExpiresAt.getTime()).toBe(
decode(rotated.tokens.refreshToken).exp * 1000,
);
const third = await refreshApp(rotated.tokens.refreshToken);
if (!third.tokens) throw new Error('회전해야 한다');
expect((await failureOf(refreshApp(first.refreshToken)))?.code).toBe(
'AUTH_001',
);
expect(sessions.only().revokedReason).toBe('refresh-token-reused');
});
it('앱 세션의 30초 유예는 새 토큰 없이 200 으로 답하고 그 세션을 끝낸다 — 끝낸 세션의 새 토큰도 못 쓴다 (웹은 세션을 남긴다)', async () => {
const first = await appLogin();
const rotated = await refreshApp(first.refreshToken);
if (!rotated.tokens) throw new Error('회전해야 한다');
const grace = await refreshApp(first.refreshToken);
expect(grace.tokens).toBeNull();
expect(grace.user).toEqual({ email: ARA, name: '김아라' });
expect(grace.state).toEqual({
restriction: null,
twoFactorDeadline: null,
});
expect(sessions.only().revokedReason).toBe('app-refresh-grace');
expect(
(await failureOf(refreshApp(rotated.tokens.refreshToken)))?.context,
).toMatchObject({ reason: 'session-revoked' });
expect(logs.join('\n')).toMatch(/\[AUTH_001\].*앱 세션의 갱신 유예/);
// 웹 세션의 유예는 그대로 — 세션을 남기고 이긴 쪽의 토큰을 쓴다
sessions.rows.clear();
const web = await loginToken();
const next = await rotateFrom(web);
expect((await service.refresh(web)).tokens).toBeNull();
expect(sessions.only().revokedAt).toBeNull();
await expect(rotateFrom(next)).resolves.toEqual(expect.any(String));
});
it('같은 앱 토큰으로 동시에 갱신하면 하나만 회전하고, 겹친 갱신(유예)이 그 앱 세션을 끝낸다', async () => {
const first = await appLogin();
const results = await Promise.all(
Array.from({ length: 3 }, () => refreshApp(first.refreshToken)),
);
expect(results.filter((result) => result.tokens !== null)).toHaveLength(
1,
);
expect(sessions.only().revokedReason).toBe('app-refresh-grace');
});
it('앱 유예의 세션 끝내기가 실패하면 유예로 답하지 않고 던진다 — 세션을 남긴 채 200 을 주지 않는다', async () => {
const first = await appLogin();
await refreshApp(first.refreshToken);
sessions.revoke.mockRejectedValueOnce(new Error('db down'));
await expect(refreshApp(first.refreshToken)).rejects.toThrow('db down');
});
it('빈 글 · 길이 상한을 넘는 리프레시 토큰은 서명을 보기 전에 AUTH_001 (웹 · 앱 같다)', async () => {
const long = 'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1);
for (const failure of [
await failureOf(refreshApp(long)),
await failureOf(service.refresh(long)),
]) {
expect(failure?.code).toBe('AUTH_001');
expect(failure?.context).toMatchObject({
reason: 'refresh-token-too-long',
});
}
expect((await failureOf(refreshApp('')))?.context).toMatchObject({
reason: 'refresh-token-missing',
});
});
it('웹 토큰은 앱 갱신에서 · 앱 토큰은 웹 갱신에서 받지 않는다 — AUTH_001 이고 세션은 끊지 않는다', async () => {
const app = await appLogin();
const web = await loginToken();
for (const failure of [
await failureOf(service.refresh(app.refreshToken)),
await failureOf(refreshApp(web)),
]) {
expect(failure?.code).toBe('AUTH_001');
expect(failure?.context).toMatchObject({
reason: 'refresh-token-client-mismatch',
});
}
expect(sessions.revoke).not.toHaveBeenCalled();
// 제 길로는 그대로 회전한다
await expect(refreshApp(app.refreshToken)).resolves.toMatchObject({
tokens: expect.anything() as unknown,
});
await expect(rotateFrom(web)).resolves.toEqual(expect.any(String));
});
it('세션 표의 client 가 토큰과 다르면 없는 세션처럼 AUTH_001 — 재사용으로 끊지 않는다', async () => {
const app = await appLogin();
sessions.only().client = 'web';
const failure = await failureOf(refreshApp(app.refreshToken));
expect(failure?.code).toBe('AUTH_001');
expect(failure?.context).toMatchObject({ reason: 'session-missing' });
expect(sessions.revoke).not.toHaveBeenCalled();
});
describe('앱 로그아웃 (logoutApp)', () => {
it('리프레시 토큰으로 세션을 끝낸다 — 그 뒤 갱신은 AUTH_001', async () => {
const tokens = await appLogin();
await service.logoutApp(tokens.refreshToken, undefined);
expect(sessions.only().revokedReason).toBe('logout');
expect((await failureOf(refreshApp(tokens.refreshToken)))?.code).toBe(
'AUTH_001',
);
expect(logs.join('\n')).toMatch(
/로그아웃: a\*\*\*@arasystem\.test \(앱\)/,
);
});
it('리프레시 토큰이 없거나 틀리면(빈 글 · 길이 상한 넘음 · 위조 · 웹 토큰) 액세스 토큰의 sid 로 끝낸다 — 만료된 액세스 토큰도 서명이 맞으면', async () => {
const web = await loginToken();
for (const refreshToken of [
undefined,
'',
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
'garbage',
web,
]) {
const tokens = await appLogin();
const { sid } = decode(tokens.accessToken);
await service.logoutApp(refreshToken, tokens.accessToken);
expect(sessions.rows.get(sid!)?.revokedReason).toBe('logout');
}
// 웹 세션은 그대로다 (웹 토큰은 앱 로그아웃에서 쓰지 않는다)
expect(sessions.rows.get(decode(web).sid!)?.revokedAt).toBeNull();
const tokens = await appLogin();
const { sid } = decode(tokens.accessToken);
const expiredAccess = sign(
{ sub: ARA, typ: 'access', name: '김아라', sid, cli: 'app' },
-60,
);
await service.logoutApp(undefined, expiredAccess);
expect(sessions.rows.get(sid!)?.revokedReason).toBe('logout');
});
it('어느 쪽도 맞지 않으면 아무것도 끝내지 않고 성공한다 — 없음 · 위조 · 웹 액세스 토큰 · 리프레시 토큰을 Bearer 에', async () => {
const web = await service.login(ARA, PASSWORD, CLIENT_IP);
if (web.status !== 'signedIn') throw new Error('로그인해야 한다');
const app = await appLogin();
for (const [refreshToken, accessToken] of [
[undefined, undefined],
['garbage', 'garbage'],
[undefined, web.tokens.accessToken],
[undefined, app.refreshToken],
] as const) {
await expect(
service.logoutApp(refreshToken, accessToken),
).resolves.toBeUndefined();
}
expect(sessions.revoke).not.toHaveBeenCalled();
});
it('세션 끝내기가 실패해도 던지지 않는다 — 오류 로그만', async () => {
const tokens = await appLogin();
sessions.revoke.mockRejectedValueOnce(new Error('db down'));
await expect(
service.logoutApp(tokens.refreshToken, undefined),
).resolves.toBeUndefined();
expect(logs.join('\n')).toMatch(/\[SYS_001\].*로그아웃 세션 폐기/);
});
});
describe('지금 세션 (requestSessionId — 앱은 sid, 웹은 리프레시 쿠키)', () => {
it('앱 요청은 액세스 토큰의 sid · 웹 요청은 리프레시 쿠키가 가리키는 세션', async () => {
const web = await loginToken();
const webSid = decode(web).sid!;
const APP_SID = '11111111-1111-4111-8111-111111111111';
await expect(
service.requestSessionId(
{ email: ARA, name: '김아라', sessionId: APP_SID, client: 'app' },
web,
),
).resolves.toBe(APP_SID);
await expect(
service.requestSessionId(
{ email: ARA, name: '김아라', sessionId: APP_SID },
web,
),
).resolves.toBe(webSid);
await expect(
service.requestSessionId({ email: ARA, name: '김아라' }, undefined),
).resolves.toBeNull();
});
it('앱 리프레시 토큰은 쿠키로 와도 웹의 지금 세션이 아니다', async () => {
const app = await appLogin();
await expect(
service.currentSessionId(app.refreshToken, ARA),
).resolves.toBeNull();
});
it('앱이 비밀번호를 바꾸면 그 앱 세션(sid)만 남기고 다른 세션 · 웹 세션을 끊는다', async () => {
const web = await loginToken();
const app = await appLogin();
const appSid = decode(app.accessToken).sid!;
await service.changePassword(
{ email: ARA, name: '김아라', sessionId: appSid, client: 'app' },
PASSWORD,
'new-horse-battery-staple',
CLIENT_IP,
undefined,
);
expect(sessions.revokeAllOf).toHaveBeenCalledWith(
ARA,
'password-changed',
expect.any(Date),
appSid,
);
expect(sessions.rows.get(appSid)?.revokedAt).toBeNull();
expect(sessions.rows.get(decode(web).sid!)?.revokedReason).toBe(
'password-changed',
);
await expect(refreshApp(app.refreshToken)).resolves.toMatchObject({
tokens: expect.anything() as unknown,
});
});
});
});
});
+231 -42
View File
@@ -3,14 +3,18 @@ import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import type {
AuthenticatedUser,
SessionClient,
} from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { isAppPasswordShape } from '@/modules/account-security/app-password';
import { AppPasswordsService } from '@/modules/account-security/app-passwords.service';
import {
LoginEventsService,
type WebmailLoginAttempt,
} from '@/modules/account-security/login-events.service';
import type {
LoginFailureReason,
SignInMethod,
} from '@/modules/account-security/login-event.entity';
import { LoginEventsService } from '@/modules/account-security/login-events.service';
import {
type CodeCheck,
TwoFactorCodeGuard,
@@ -44,11 +48,13 @@ import {
type RefreshState,
} from './auth-session.policy';
import { AuthSessionRepository } from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import { APP_TOKEN_CLIENT, type AuthTokenPayload } from './auth-token.types';
import {
JWT_ALGORITHM,
MAX_REFRESH_TOKEN_LENGTH,
REFRESH_REUSE_GRACE_MS,
SESSION_ABSOLUTE_TTL_SECONDS,
SESSION_ID_PATTERN,
} from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { isEmployeeAddress, normalizeEmail } from './employee-address';
@@ -100,6 +106,11 @@ export interface ClientInfo {
readonly ip: string;
/** User-Agent 원문 — 없으면 null */
readonly userAgent?: string | null;
/**
* 웹(쿠키) · 앱(토큰 본문 — 계약 1절 "앱 로그인") — 없으면 웹. 로그인은 그 클라이언트의 세션을 열고(로그인 기록의 방식 webmail · app),
* 갱신은 같은 클라이언트의 리프레시 토큰만 받는다
*/
readonly sessionClient?: SessionClient;
}
/** 로그인의 선택 값 */
@@ -107,6 +118,8 @@ export interface LoginOptions {
/** 2단계 인증 코드 (숫자 6자리 — 형식은 DTO 가 본다) */
readonly otpCode?: string;
readonly userAgent?: string | null;
/** 웹(쿠키) · 앱(토큰 본문) — 없으면 웹. 한도 · 코드 가드 · 앱 비밀번호 거절 · 막는 계정은 둘이 같다 */
readonly sessionClient?: SessionClient;
}
/** 로그인 · 갱신에서 읽은 프로필 — 비밀번호가 만료된 계정은 메일 기능 없이 정식 주소만 안다 (13단계) */
@@ -120,11 +133,23 @@ interface LoadedProfile {
interface RefreshClaims {
readonly sub: string;
readonly sid: string;
/** 토큰의 표시(cli)로 안 클라이언트 — 표시가 없으면 웹 */
readonly client: SessionClient;
}
/** 세션 id 형식 — DB 의 uuid 열에 넣기 전에 확인한다 (형식이 틀리면 DB 오류가 SYS_001 로 새어 나간다) */
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** 서명을 확인한 앱 액세스 토큰에서 꺼낸 값 — 앱 로그아웃이 sid 로 세션을 끝낸다 */
interface AppAccessClaims {
readonly sub: string;
readonly sid: string;
}
/** 클라이언트 → 로그인 기록의 방식 (계약 5절 "로그인 기록" — 웹메일 · 앱) */
function signInMethodOf(client: ClientInfo): SignInMethod {
return client.sessionClient === 'app' ? 'app' : 'webmail';
}
/** JWT 의 시각(iat · exp)은 초 단위다 */
const MS_PER_SECOND = 1000;
function toUser(profile: MailAccountProfile): AuthenticatedUser {
return { email: profile.email, name: profile.name };
@@ -202,6 +227,9 @@ export class AuthService {
* 코드의 한도 · 기억 · 로그인 기록은 정식 주소로 잡는다
* 5. 틀림 → AUTH_004
* 서버가 판정한 시도(성공 · AUTH_004 · AUTH_006)는 로그인 기록에 남긴다 — 한도로 막은 요청 · 사내 도메인 밖 · 막은 계정은 남기지 않는다
*
* 앱 로그인(`options.sessionClient: 'app'` — 계약 1절 "앱 로그인")도 이 함수 하나를 지난다 — 실패 카운터 · 코드 가드 · 앱 비밀번호 거절 ·
* 막는 계정이 웹과 같다(앱 경로로 한도를 비켜 가지 못하게). 다른 것은 세션의 클라이언트(app — 토큰에 표시 cli)와 로그인 기록의 방식(app)뿐이다
*/
async login(
email: string,
@@ -218,6 +246,7 @@ export class AuthService {
const client: ClientInfo = {
ip: clientIp,
userAgent: options.userAgent ?? null,
sessionClient: options.sessionClient ?? 'web',
};
// IP 를 알 수 없는 요청(소켓이 이미 닫힘 등)도 한 묶음으로 센다.
@@ -300,7 +329,8 @@ export class AuthService {
const tokens = await this.startSession(profile, client, state);
// 만료된 계정의 이름은 주소의 @ 앞뿐이다 — 사내 목록의 이름을 덮지 않는다 (바꾼 뒤의 로그인이 진짜 이름으로 남긴다)
if (!passwordExpired) await this.recordInDirectory(profile);
await this.loginEvents.recordWebmail({
await this.loginEvents.recordSignIn({
method: signInMethodOf(client),
email: profile.email,
isSuccess: true,
isTwoFactor,
@@ -309,7 +339,7 @@ export class AuthService {
userAgent: client.userAgent ?? null,
});
this.logger.log(
`로그인: ${maskEmail(profile.email)}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`,
`로그인: ${maskEmail(profile.email)}${client.sessionClient === 'app' ? ' (앱)' : ''}${isTwoFactor ? ' (2단계 인증)' : ''}${state.restriction !== null ? ` (제한 ${state.restriction})` : ''}`,
);
return { status: 'signedIn', user: toUser(profile), tokens, state };
}
@@ -363,13 +393,14 @@ export class AuthService {
return loaded;
}
/** 웹메일 로그인 실패를 기록에 남긴다 (던지지 않는다) */
/** 웹메일 · 앱 로그인 실패를 기록에 남긴다 (던지지 않는다) */
private async recordLoginFailure(
address: string,
client: ClientInfo,
failureReason: NonNullable<WebmailLoginAttempt['failureReason']>,
failureReason: LoginFailureReason,
): Promise<void> {
await this.loginEvents.recordWebmail({
await this.loginEvents.recordSignIn({
method: signInMethodOf(client),
email: address,
isSuccess: false,
isTwoFactor: false,
@@ -399,6 +430,7 @@ export class AuthService {
/**
* 리프레시 쿠키만으로 두 토큰을 새로 발급한다(1회용 회전) — 액세스 쿠키가 없거나 만료돼도 된다.
* 앱(`client.sessionClient: 'app'`)은 본문의 리프레시 토큰으로 같은 규칙을 탄다 (계약 1절 "앱 로그인") — 웹 토큰은 앱 갱신에서, 앱 토큰은 웹 갱신에서 받지 않는다(AUTH_001).
*
* - 이미 쓴 토큰이 오면 탈취로 보고 그 세션 전체를 끊는다 → AUTH_001
* - 회전 직후의 바로 이전 토큰은 유예한다(탭 여러 개의 동시 갱신) → 새 토큰 없이 성공
@@ -415,7 +447,11 @@ export class AuthService {
});
}
const claims = await this.verifyRefreshToken(refreshToken, false);
const claims = await this.verifyRefreshToken(
refreshToken,
false,
client.sessionClient ?? 'web',
);
const presentedHash = hashRefreshToken(refreshToken);
const now = new Date();
const session = await this.sessions.findById(claims.sid);
@@ -446,7 +482,8 @@ export class AuthService {
* 2단계 인증은 그대로 남는다
* - 세지 않는 실패: 지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절함(400 AUTH_005 — 맞혀 보기가 아니다) · 엔진 규칙 거절(VAL_001) · 엔진 장애
* - 바뀌면 그 직원의 **다른** 로그인 세션을 모두 끊고(끊은 세션의 액세스 토큰은 가드가 바로 막는다) **앱 비밀번호를 모두 지운다** —
* 이 요청의 리프레시 쿠키(Path=/api/auth 라 함께 온다)가 가리키는 세션만 남긴다. 쿠키로 이 세션을 알 수 없으면 모두 끊는다.
* 이 요청의 세션만 남긴다(requestSessionId — 웹은 리프레시 쿠키(Path=/api/auth 라 함께 온다), 앱(Bearer)은 액세스 토큰의 sid).
* 이 세션을 알 수 없으면 모두 끊는다.
* 결과를 알 수 없게 끝났어도(엔진이 바꿨을 수 있다) 둘 다 해 두고 503 MAIL_001 로 답한다. 끊기 · 지우기에 실패해도 던지지 않는다(error 로그).
* 시도는 끊기가 끝난 뒤에 끝낸다 — 그동안 같은 주소의 로그인은 처리 중(REQ_001)이라 새 비밀번호로 막 만든 세션이 함께 끊기지 않는다
* 비밀번호 · 코드는 로그 · 오류 context 에 남기지 않는다. 바꾼 일은 info 로그에 가린 주소와 끊은 세션 · 지운 앱 비밀번호 수만.
@@ -536,7 +573,12 @@ export class AuthService {
throw error;
}
await this.settlePasswordChange(outcome, ticket, address, refreshToken);
await this.settlePasswordChange(
outcome,
ticket,
address,
await this.requestSessionId(user, refreshToken),
);
await this.advancePasswordChangeRestriction(
user,
address,
@@ -548,12 +590,12 @@ export class AuthService {
}
}
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기) */
/** 비밀번호 바꾸기의 엔진 판단 → 응답 (세션 끊기 · 앱 비밀번호 지우기 · 시도 끝내기). keepSessionId — 남길 이 요청의 세션(모르면 null — 모두 끊는다) */
private async settlePasswordChange(
outcome: PasswordChangeOutcome,
ticket: LoginAttemptTicket,
address: string,
refreshToken: string | undefined,
keepSessionId: string | null,
): Promise<void> {
switch (outcome) {
case 'current-password-rejected':
@@ -598,7 +640,7 @@ export class AuthService {
ticket,
'abort',
address,
refreshToken,
keepSessionId,
'password-change-uncertain',
);
throw new BusinessException('MAIL_001', {
@@ -615,7 +657,7 @@ export class AuthService {
ticket,
'succeed',
address,
refreshToken,
keepSessionId,
'password-changed',
);
this.logger.log(
@@ -633,13 +675,13 @@ export class AuthService {
ticket: LoginAttemptTicket,
settle: 'succeed' | 'abort',
address: string,
refreshToken: string | undefined,
keepSessionId: string | null,
reason: SessionRevokedReason,
): Promise<{ revokedSessions: number; removedAppPasswords: number }> {
try {
const revokedSessions = await this.revokeOtherSessions(
address,
refreshToken,
keepSessionId,
reason,
);
const removedAppPasswords =
@@ -703,16 +745,15 @@ export class AuthService {
}
/**
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수.
* 비밀번호를 바꾼(또는 바꿨을 수 있는) 뒤 그 직원의 다른 세션을 끊는다 → 끊은 수. `keepId` 세션만 남긴다(null 이면 모두).
* 여기서 실패해도 던지지 않는다 — 비밀번호는 이미 바뀌었을 수 있어, 실패로 답하면 사용자가 옛 비밀번호로 다시 시도해
* AUTH_005 를 받는다. 대신 error 로그를 남긴다 (다른 세션이 남아 있다 — 운영자가 끊을 수 있게 가린 주소와 함께)
*/
private async revokeOtherSessions(
address: string,
refreshToken: string | undefined,
keepId: string | null,
reason: SessionRevokedReason,
): Promise<number> {
const keepId = await this.currentSessionId(refreshToken, address);
try {
return await this.sessions.revokeAllOf(
address,
@@ -729,8 +770,8 @@ export class AuthService {
}
/**
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
* 비밀번호 바꾸기 · 로그인된 브라우저 목록(지금 세션 — 계약 5절)이 쓴다
* 리프레시 쿠키가 가리키는 그 직원의 세션 id — 없거나 · 위조거나 · 다른 직원의 토큰 · 앱 토큰이면 null (만료는 따지지 않는다 — 고르기만 한다).
* 웹 요청의 지금 세션이다 — 비밀번호 바꾸기 · 로그인된 브라우저 목록(계약 5절)이 requestSessionId 로 쓴다
*/
async currentSessionId(
refreshToken: string | undefined,
@@ -738,13 +779,26 @@ export class AuthService {
): Promise<string | null> {
if (!refreshToken) return null;
try {
const claims = await this.verifyRefreshToken(refreshToken, true);
const claims = await this.verifyRefreshToken(refreshToken, true, 'web');
return normalizeEmail(claims.sub) === address ? claims.sid : null;
} catch {
return null;
}
}
/**
* 이 요청의 세션 id (계약 1절 "앱 로그인 › 지금 세션" · 5절 "로그인된 브라우저") — 던지지 않는다.
* 앱(Bearer)은 리프레시 쿠키가 없으므로 액세스 토큰의 sid 로, 웹은 지금처럼 리프레시 쿠키(Path=/api/auth)로 안다 — 모르면 null.
* 비밀번호 바꾸기(이 세션만 남기기) · 로그인된 기기 목록(isCurrent · 다른 곳 모두 끊기)이 쓴다
*/
async requestSessionId(
user: AuthenticatedUser,
refreshToken: string | undefined,
): Promise<string | null> {
if (user.client === 'app') return user.sessionId ?? null;
return this.currentSessionId(refreshToken, normalizeEmail(user.email));
}
/**
* me 의 세션 상태 (13단계 — 계약 6절) — 제한은 **세션 표의 지금 값**(푼 뒤 옛 토큰으로 새로고침해도 마친 단계로 되돌아가지 않게),
* 유예의 시작 시각은 정책과 켜짐(우리 기록 → 엔진)으로 다시 본다 — 그사이 켰거나 정책이 바뀌었을 수 있다.
@@ -776,7 +830,7 @@ export class AuthService {
/**
* 로그아웃 — 서버의 세션을 끊는다. 항상 성공으로 끝난다(계약: 항상 200):
* 토큰이 없거나 이미 쓸 수 없어도, 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
* 토큰이 없거나 이미 쓸 수 없어도(앱 토큰 포함), 세션 폐기에 실패해도(오류 로그만) 쿠키는 컨트롤러가 지운다.
*/
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
@@ -784,17 +838,47 @@ export class AuthService {
let claims: RefreshClaims;
try {
// 만료된 토큰으로도 그 세션은 끊을 수 있게 한다 (서명은 확인한다)
claims = await this.verifyRefreshToken(refreshToken, true);
claims = await this.verifyRefreshToken(refreshToken, true, 'web');
} catch {
return;
}
await this.endSession(claims.sid, claims.sub, '');
}
/**
* 앱 로그아웃 (계약 1절 "앱 로그인") — 던지지 않는다(본문 모양은 DTO 가 본다 — 글이 아닌 refreshToken · 모르는 칸은 400).
* 본문의 리프레시 토큰으로 세션을 찾고, 없거나 틀리면(빈 글 · 길이 상한 넘음 · 서명 · 종류 · 웹 토큰) Bearer 액세스 토큰의 sid 로 끝낸다 —
* 앱이 이어 쓸 리프레시 토큰을 잃었을 때 "로그인된 기기"에 14일 남지 않게.
* 두 토큰 모두 만료돼도 서명이 맞으면 그 세션을 끝낸다(끝내기만 하므로 — 웹 로그아웃과 같다). 어느 쪽도 맞지 않으면 아무것도 하지 않는다
*/
async logoutApp(
refreshToken: string | undefined,
accessToken: string | undefined,
): Promise<void> {
// 빈 글 · 틀린 리프레시 토큰(길이 · 서명 · 종류 · 웹 토큰)은 null — 액세스 토큰의 sid 로 넘어간다
const byRefresh = refreshToken
? await this.verifyRefreshToken(refreshToken, true, 'app').catch(
() => null,
)
: null;
const claims =
byRefresh ??
(accessToken ? await this.verifyAppAccessToken(accessToken) : null);
if (claims) await this.endSession(claims.sid, claims.sub, ' (앱)');
}
/** 로그아웃의 세션 끝내기 — 실패해도 던지지 않는다(오류 로그만). 끊은 세션은 끊은 세션 기억에도 들어간다(세션 표 저장소) */
private async endSession(
sessionId: string,
email: string,
note: string,
): Promise<void> {
try {
await this.sessions.revoke(claims.sid, 'logout', new Date());
this.logger.log(`로그아웃: ${maskEmail(claims.sub)}`);
await this.sessions.revoke(sessionId, 'logout', new Date());
this.logger.log(`로그아웃: ${maskEmail(email)}${note}`);
} catch (error) {
this.logger.error(
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (쿠키는 지운다): ${errorMessage(error)}`,
`[SYS_001]${traceTag()} 로그아웃 세션 폐기에 실패했습니다 (응답은 성공): ${errorMessage(error)}`,
);
}
}
@@ -826,6 +910,22 @@ export class AuthService {
);
}
/**
* 앱 세션의 갱신 유예 — 세션을 끝낸다 (계약 1절 "앱 로그인"). 앱은 갱신을 한 번에 하나만 하므로 유예는 "회전 응답을 잃음"이나
* "훔친 토큰을 누가 먼저 씀" 둘뿐이고, 어느 쪽이든 앱에는 이어 쓸 리프레시 토큰이 없다 — 남겨 두면 아무도 쥐지 않은 세션이 14일 남거나
* 먼저 쓴 쪽(공격자일 수 있다)이 재사용 감지 없이 이어 쓴다. 끝낸 세션의 액세스 토큰은 끊은 세션 기억으로 바로 막힌다.
* 끊기가 실패하면 던진다(500) — 유예로 답하고 세션을 남기지 않게 (재사용 감지와 같다)
*/
private async endAppGraceSession(
claims: RefreshClaims,
now: Date,
): Promise<void> {
await this.sessions.revoke(claims.sid, 'app-refresh-grace', now);
this.logger.warn(
`[AUTH_001]${traceTag()} 앱 세션의 갱신 유예 — 회전 직후의 이전 토큰이 다시 와서 세션을 끝냈습니다(응답을 잃었거나 토큰이 두 번 쓰였습니다): user=${maskEmail(claims.sub)}, sid=${claims.sid}`,
);
}
private async resolveRefresh(
state: RefreshState,
session: AuthSessionEntity | null,
@@ -839,6 +939,8 @@ export class AuthService {
}
if (state === 'grace') {
// 앱 세션의 유예는 세션을 끝낸다 — 응답은 웹과 같은 200 tokens: null (계약 1절 "앱 로그인")
if (session?.client === 'app') await this.endAppGraceSession(claims, now);
const { profile } = await this.profileForGrace(claims.sub);
return {
user: toUser(profile),
@@ -917,6 +1019,7 @@ export class AuthService {
expiresAt,
now,
sessionState,
session.client,
);
const rotated = await this.sessions.rotate({
@@ -937,6 +1040,8 @@ export class AuthService {
const latest = await this.sessions.findById(claims.sid);
const state = this.classify(latest, claims, presentedHash, now);
if (state === 'grace') {
// 같은 토큰의 두 갱신이 겹쳤다 — 앱은 한 번에 하나만 하므로 앱 세션이면 끝낸다 (위 resolveRefresh 와 같다)
if (latest?.client === 'app') await this.endAppGraceSession(claims, now);
return { user: toUser(profile), tokens: null, state: sessionState };
}
if (state === 'current') {
@@ -1008,7 +1113,10 @@ export class AuthService {
}
}
/** 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록) */
/**
* 새 세션 — 절대 만료 30일, 미사용 만료는 리프레시 기간. 로그인한 클라이언트의 IP · User-Agent 를 함께 둔다 (로그인된 브라우저 목록).
* 클라이언트(웹 · 앱)는 세션 표에 남기고 토큰에도 싣는다(앱이면 표시 cli) — 갱신은 같은 클라이언트로만 된다
*/
private async startSession(
profile: MailAccountProfile,
client: ClientInfo,
@@ -1016,6 +1124,7 @@ export class AuthService {
): Promise<SessionTokens> {
const now = new Date();
const id = randomUUID();
const sessionClient = client.sessionClient ?? 'web';
const absoluteExpiresAt = new Date(
now.getTime() + SESSION_ABSOLUTE_TTL_SECONDS * 1000,
);
@@ -1024,7 +1133,14 @@ export class AuthService {
this.settings.refreshTtlSeconds,
absoluteExpiresAt,
);
const tokens = await this.signTokens(profile, id, expiresAt, now, state);
const tokens = await this.signTokens(
profile,
id,
expiresAt,
now,
state,
sessionClient,
);
await this.sessions.create({
id,
@@ -1040,14 +1156,27 @@ export class AuthService {
ip: client.ip || null,
userAgent: truncateUserAgent(client.userAgent),
restriction: state.restriction,
client: sessionClient,
});
return tokens;
}
/**
* 리프레시 토큰의 서명 · 종류 · 클라이언트를 본다 — 웹 경로(쿠키)는 표시가 없는 토큰만, 앱 경로(본문)는 앱 표시(cli)가 있는 토큰만 받는다.
* 다르면 AUTH_001 (세션은 끊지 않는다 — 재사용이 아니다). 길이 상한을 넘으면 서명을 보기 전에 틀린 토큰이다.
* 토큰 원문은 오류 context 에 싣지 않는다
*/
private async verifyRefreshToken(
token: string,
ignoreExpiration: boolean,
expectedClient: SessionClient,
): Promise<RefreshClaims> {
if (token.length > MAX_REFRESH_TOKEN_LENGTH) {
// 우리가 만든 토큰이 아니다 — 아주 긴 값으로 서명 확인을 붙잡지 못하게 (계약 1절 "앱 로그인": 빈 글 · 긴 글은 틀린 토큰과 같다)
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-too-long', length: token.length },
});
}
let payload: AuthTokenPayload;
try {
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
@@ -1064,24 +1193,67 @@ export class AuthService {
});
}
const { typ, sub, sid } = payload;
const { typ, sub, sid, cli } = payload;
if (
typ !== 'refresh' ||
typeof sub !== 'string' ||
typeof sid !== 'string' ||
!SESSION_ID_PATTERN.test(sid)
!SESSION_ID_PATTERN.test(sid) ||
(cli !== undefined && cli !== APP_TOKEN_CLIENT)
) {
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 등
// 액세스 토큰을 리프레시 쿠키에 넣은 경우·세션 저장 전에 발급된 토큰 · 모르는 표시 등
throw new BusinessException('AUTH_001', {
context: { reason: 'not-a-refresh-token' },
});
}
const client: SessionClient = cli === APP_TOKEN_CLIENT ? 'app' : 'web';
if (client !== expectedClient) {
// 앱 토큰을 웹 갱신(쿠키)에 · 웹 토큰을 앱 갱신(본문)에 — 서로 바꿔 쓰지 못한다 (계약 1절 "앱 로그인")
throw new BusinessException('AUTH_001', {
context: {
reason: 'refresh-token-client-mismatch',
expected: expectedClient,
},
});
}
return { sub, sid, client };
}
/**
* 앱 로그아웃의 Bearer 액세스 토큰 → 세션 (서명 · 종류 · 앱 표시 · sid 모양). 만료는 따지지 않는다(끝내기만 한다).
* 맞지 않으면 null — 앱 로그아웃은 토큰이 틀려도 200 이다(400 은 본문 모양이 틀릴 때뿐)
*/
private async verifyAppAccessToken(
token: string,
): Promise<AppAccessClaims | null> {
let payload: AuthTokenPayload;
try {
payload = await this.jwt.verifyAsync<AuthTokenPayload>(token, {
secret: this.settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
// 만료를 보지 않는다(시간 상한도 없다) — 세션을 끝내기만 하고 권한을 주지 않으므로. 웹 logout 이 만료된 리프레시 토큰으로 끝내는 것과 같은 결
ignoreExpiration: true,
});
} catch {
return null;
}
const { typ, sub, sid, cli } = payload;
if (
typ !== 'access' ||
cli !== APP_TOKEN_CLIENT ||
typeof sub !== 'string' ||
typeof sid !== 'string' ||
!SESSION_ID_PATTERN.test(sid)
) {
return null;
}
return { sub, sid };
}
/**
* 두 토큰을 서명한다. 리프레시 토큰은 세션의 미사용 만료에, 액세스 토큰은 그보다 늦지 않게 끝난다
* (세션보다 오래 사는 토큰을 만들지 않는다).
* (세션보다 오래 사는 토큰을 만들지 않는다). 앱 세션이면 두 토큰에 앱 표시(cli)를 싣는다 (계약 1절 "앱 로그인").
* 발급 시각(iat)을 `now` 로 정해 두어 끝나는 때(exp = iat + 수명)를 앱 응답의 accessExpiresAt · refreshExpiresAt 으로 그대로 준다
*/
private async signTokens(
profile: MailAccountProfile,
@@ -1089,12 +1261,17 @@ export class AuthService {
refreshExpiresAt: Date,
now: Date,
state: SessionState,
sessionClient: SessionClient,
): Promise<SessionTokens> {
const { jwtSecret, accessTtlSeconds } = this.settings;
const refreshTtlSeconds = Math.max(
1,
Math.floor((refreshExpiresAt.getTime() - now.getTime()) / 1000),
);
const accessExpiresIn = Math.min(accessTtlSeconds, refreshTtlSeconds);
const issuedAt = Math.floor(now.getTime() / MS_PER_SECOND);
const clientMark: Pick<AuthTokenPayload, 'cli'> =
sessionClient === 'app' ? { cli: APP_TOKEN_CLIENT } : {};
// 액세스 토큰에도 세션 id 를 싣는다 — 끊은 세션의 액세스 토큰을 가드가 바로 막는다 (계약 5절 "로그인된 브라우저")
// 제한 · 유예(13단계)도 싣는다 — 가드가 제한을 보고 me 가 그대로 돌려준다 (있을 때만)
const access: AuthTokenPayload = {
@@ -1102,22 +1279,26 @@ export class AuthService {
typ: 'access',
name: profile.name,
sid: sessionId,
...clientMark,
...(state.restriction !== null ? { rst: state.restriction } : {}),
...(state.twoFactorDeadline !== null
? { tfd: state.twoFactorDeadline }
: {}),
iat: issuedAt,
};
const refresh: AuthTokenPayload = {
sub: profile.email,
typ: 'refresh',
sid: sessionId,
...clientMark,
iat: issuedAt,
};
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(access, {
secret: jwtSecret,
algorithm: JWT_ALGORITHM,
expiresIn: Math.min(accessTtlSeconds, refreshTtlSeconds),
expiresIn: accessExpiresIn,
jwtid: randomUUID(),
}),
this.jwt.signAsync(refresh, {
@@ -1128,7 +1309,15 @@ export class AuthService {
}),
]);
return { accessToken, refreshToken, refreshTtlSeconds };
return {
accessToken,
refreshToken,
refreshTtlSeconds,
accessExpiresAt: new Date((issuedAt + accessExpiresIn) * MS_PER_SECOND),
refreshExpiresAt: new Date(
(issuedAt + refreshTtlSeconds) * MS_PER_SECOND,
),
};
}
/** 동시 갱신 유예의 프로필 — 기억한 세션으로 읽는다. 비밀번호가 만료된 계정은 주소의 @ 앞을 이름으로 (13단계) */
@@ -0,0 +1,62 @@
import { BadRequestException } from '@nestjs/common';
import { createValidationPipe } from '@/app.setup';
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
import { AppLogoutDto, AppRefreshDto } from './app-auth.dto';
/** 전역 ValidationPipe 그대로(createValidationPipe) — 통과하면 변환된 본문, 아니면 400 (전역 필터가 VAL_001 로 바꾼다) */
async function through(
metatype: new () => object,
body: unknown,
): Promise<object | 'VAL_001'> {
try {
return (await createValidationPipe().transform(body, {
type: 'body',
metatype,
})) as object;
} catch (error) {
if (error instanceof BadRequestException) return 'VAL_001';
throw error;
}
}
describe('앱 갱신 · 로그아웃 본문 (계약 1절 "앱 로그인" — 앱이 400 에 갇히지 않게)', () => {
const long = 'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1);
it('갱신 — refreshToken 이 없거나 글이 아니거나 모르는 칸이면 400 VAL_001', async () => {
for (const body of [
{},
undefined,
{ refreshToken: null },
{ refreshToken: 12345 },
{ refreshToken: 'a.b.c', extra: true },
]) {
await expect(through(AppRefreshDto, body)).resolves.toBe('VAL_001');
}
});
it(`갱신 — 빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘는 글은 본문을 통과한다 (틀린 토큰 — 서비스가 서명을 보기 전에 401 AUTH_001)`, async () => {
for (const refreshToken of ['a.b.c', '', long]) {
await expect(through(AppRefreshDto, { refreshToken })).resolves.toEqual({
refreshToken,
});
}
});
it('로그아웃 — 빈 본문 · 칸 없음 · null · 빈 글 · 긴 글은 받는다(틀린 토큰은 Bearer 의 sid 로), 글이 아니거나 모르는 칸만 400 VAL_001', async () => {
await expect(through(AppLogoutDto, {})).resolves.toEqual({});
await expect(through(AppLogoutDto, undefined)).resolves.toEqual({});
await expect(
through(AppLogoutDto, { refreshToken: null }),
).resolves.toMatchObject({});
for (const refreshToken of ['a.b.c', '', long]) {
await expect(through(AppLogoutDto, { refreshToken })).resolves.toEqual({
refreshToken,
});
}
for (const body of [{ refreshToken: 12345 }, { other: 'x' }]) {
await expect(through(AppLogoutDto, body)).resolves.toBe('VAL_001');
}
});
});
@@ -0,0 +1,147 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import type { SessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
import {
AuthUserDto,
RESTRICTION_PROPERTY,
TWO_FACTOR_DEADLINE_PROPERTY,
} from './auth-user.dto';
/**
* 앱 전용 토큰 경로의 본문 · 응답 (계약 1절 "앱 로그인") — 세션 규칙은 웹과 같고 토큰을 나르는 길만 다르다.
* 토큰은 이 응답들에만 싣는다(Cache-Control: private, no-store). 로그 · 오류 context 에는 남기지 않는다.
*/
/** 토큰 예시 — 진짜 토큰 모양을 문서에 두지 않는다 */
const TOKEN_EXAMPLE = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…';
/**
* 리프레시 토큰 칸은 글인지만 본다 (계약 1절 "앱 로그인" — 앱이 400 에 갇히지 않게).
* 빈 글 · 길이 상한(MAX_REFRESH_TOKEN_LENGTH) 넘음은 본문 오류가 아니라 틀린 토큰이다 — 서비스가 서명을 보기 전에 가른다
* (갱신 401 AUTH_001 → 앱은 세션 끝으로, 로그아웃은 Bearer 의 sid 로)
*/
const REFRESH_TOKEN_MESSAGE = '리프레시 토큰이 올바르지 않습니다.';
/** 앱이 받는 두 토큰 — 리프레시 토큰은 보안 저장소에, 액세스 토큰은 메모리에만 둔다 (앱 몫) */
export class AppTokensDto {
@ApiProperty({
description:
'액세스 JWT — 보호 API 에 `Authorization: Bearer` 로 싣는다(앱 표시 cli: app — 쿠키로는 받지 않는다)',
example: TOKEN_EXAMPLE,
})
accessToken!: string;
@ApiProperty({
description:
'액세스 토큰이 끝나는 때 (ISO 8601 UTC — 토큰의 exp). 앱은 30초 전에 미리 갱신한다',
type: String,
format: 'date-time',
example: '2026-11-06T03:00:00.000Z',
})
accessExpiresAt!: string;
@ApiProperty({
description:
'리프레시 JWT — 1회용(갱신할 때마다 새 토큰). 이미 쓴 토큰이 다시 오면 세션 전체를 끊는다(회전 직후 30초 안의 바로 이전 토큰은 유예)',
example: TOKEN_EXAMPLE,
})
refreshToken!: string;
@ApiProperty({
description:
'리프레시 토큰이 끝나는 때 (ISO 8601 UTC — 토큰의 exp) — 세션의 미사용 만료(14일, 로그인 후 30일을 넘지 않는다)',
type: String,
format: 'date-time',
example: '2026-11-20T02:00:00.000Z',
})
refreshExpiresAt!: string;
}
/** 토큰 칸의 공통 Swagger 값 — signedIn · 회전에만 차고 그 밖은 null (설명은 칸마다) */
const TOKENS_PROPERTY = {
type: AppTokensDto,
nullable: true,
} as const;
/** POST /api/auth/app/login 의 응답 — 웹 login(LoginResultDto)과 같은 평평한 칸 + tokens */
export class AppLoginResultDto {
@ApiProperty({
description:
'signedIn — 로그인했다(tokens 가 찬다) · twoFactorRequired — 2단계 인증 코드가 필요하다(tokens 는 null — 비밀번호와 코드를 함께 다시 보낸다)',
enum: ['signedIn', 'twoFactorRequired'],
example: 'signedIn',
})
status!: 'signedIn' | 'twoFactorRequired';
@ApiProperty({
description: '직원 메일 주소 — twoFactorRequired 면 null',
type: String,
nullable: true,
example: 'ara.kim@arasystem.it.kr',
})
email!: string | null;
@ApiProperty({
description: '표시 이름 — twoFactorRequired 면 null',
type: String,
nullable: true,
example: '김아라',
})
name!: string | null;
@ApiProperty({
...RESTRICTION_PROPERTY,
description: `${RESTRICTION_PROPERTY.description}. twoFactorRequired 면 null — 제한된 세션도 토큰을 받는다`,
})
restriction!: SessionRestriction | null;
@ApiProperty({
...TWO_FACTOR_DEADLINE_PROPERTY,
description: `${TWO_FACTOR_DEADLINE_PROPERTY.description}. twoFactorRequired 면 null`,
})
twoFactorDeadline!: string | null;
@ApiProperty({
...TOKENS_PROPERTY,
description:
'두 토큰 — signedIn(웹이 쿠키를 굽는 바로 그 경우)에만 차고, 그 밖(twoFactorRequired)은 null. 제한된 세션(restriction)도 토큰을 받는다',
})
tokens!: AppTokensDto | null;
}
/** POST /api/auth/app/refresh 의 응답 — 웹 refresh 와 같은 칸 + tokens */
export class AppRefreshResultDto extends AuthUserDto {
@ApiProperty({
...TOKENS_PROPERTY,
description:
'새 두 토큰(회전). 회전 직후 30초 안에 바로 이전 토큰이 다시 오면 새 토큰 없이 null — 앱은 이것을 세션 끝으로 다룬다(이어 쓸 리프레시 토큰이 없다)',
})
tokens!: AppTokensDto | null;
}
/** POST /api/auth/app/refresh — 보안 저장소의 리프레시 토큰 */
export class AppRefreshDto {
@ApiProperty({
description:
`앱 로그인 · 앱 갱신이 준 리프레시 토큰. 없거나 글이 아니면 400 VAL_001, ` +
`빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘음 · 틀린 토큰 · 웹(쿠키)의 토큰은 401 AUTH_001(서명을 보기 전에)`,
example: TOKEN_EXAMPLE,
})
@IsString({ message: REFRESH_TOKEN_MESSAGE })
refreshToken!: string;
}
/** POST /api/auth/app/logout — 리프레시 토큰은 있으면 싣는다(보안 저장소를 읽지 못했으면 빈 본문 `{}`) */
export class AppLogoutDto {
@ApiPropertyOptional({
description:
`앱의 리프레시 토큰(선택) — 이것으로 세션을 찾는다. 없거나 · 빈 글 · ${MAX_REFRESH_TOKEN_LENGTH}자 넘음 · 틀리면 ` +
'`Authorization: Bearer` 액세스 토큰의 sid 로 끝낸다(200). 글이 아니면 400 VAL_001',
example: TOKEN_EXAMPLE,
})
@IsOptional()
@IsString({ message: REFRESH_TOKEN_MESSAGE })
refreshToken?: string;
}
@@ -37,7 +37,7 @@ export class AuthSessionDto {
@ApiProperty({
description:
'브라우저 · 운영체제의 짧은 이름 — 12단계 전에 연 세션이거나 모르면 null',
'브라우저 · 운영체제의 짧은 이름 — 앱 세션은 브라우저가 ARA SYSTEM 앱(세션 표가 앱일 때만). 웹 세션은 12단계 전에 연 세션이거나 모르면 null',
type: DeviceDto,
nullable: true,
})
@@ -45,7 +45,15 @@ export class AuthSessionDto {
@ApiProperty({
description:
'이 요청의 세션(리프레시 쿠키가 가리키는 것)인가 — 맨 앞에 온다. 쿠키가 없거나 남의 것이면 모두 false',
'세션을 연 클라이언트 — web(웹메일 · 브라우저) · app(모바일 앱 — 계약 1절 "앱 로그인"). 앱 1단계 전에 연 세션은 web',
enum: ['web', 'app'],
example: 'web',
})
client!: 'web' | 'app';
@ApiProperty({
description:
'이 요청의 세션(웹은 리프레시 쿠키, 앱은 액세스 토큰의 sid 가 가리키는 것)인가 — 맨 앞에 온다. 웹 요청에 쿠키가 없거나 남의 것이면 모두 false',
example: true,
})
isCurrent!: boolean;
+125 -12
View File
@@ -1,6 +1,8 @@
import { BusinessException } from '@/common/errors/business.exception';
import type { AccessTokenRequest } from './access-token';
import type { AuthSessionEntity } from './auth-session.entity';
import type { AuthSessionRepository } from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import { JwtStrategy } from './jwt.strategy';
import type { RevokedSessions } from './revoked-sessions';
@@ -31,6 +33,22 @@ const strategy = new JwtStrategy(
const SID = '9b2d4f1e-6c3a-4e8b-a5d7-0f1e2d3c4b5a';
/** 웹 요청 — 토큰이 액세스 쿠키로 왔다 (서명 확인은 passport-jwt 몫이라 값은 상관없다) */
const COOKIE_REQUEST: AccessTokenRequest = {
headers: {},
cookies: { access_token: 'signed.cookie.token' },
};
/** 앱 요청 — 토큰이 Authorization: Bearer 로 왔다 */
const BEARER_REQUEST: AccessTokenRequest = {
headers: { authorization: 'Bearer signed.bearer.token' },
cookies: {},
};
const validateFromCookie = (payload: AuthTokenPayload) =>
strategy.validate(COOKIE_REQUEST, payload);
const validateFromBearer = (payload: AuthTokenPayload) =>
strategy.validate(BEARER_REQUEST, payload);
async function codeOf(
run: () => Promise<unknown>,
): Promise<string | undefined> {
@@ -51,7 +69,7 @@ describe('JwtStrategy.validate', () => {
it('액세스 토큰이면 직원 정보를 돌려준다', async () => {
await expect(
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -60,7 +78,7 @@ describe('JwtStrategy.validate', () => {
});
it('토큰의 만료(exp, 초)를 epoch ms 로 함께 넘긴다 — 실시간 알림이 토큰보다 오래 열려 있지 않게', async () => {
const user = await strategy.validate({
const user = await validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -76,7 +94,7 @@ describe('JwtStrategy.validate', () => {
it('토큰의 세션 id(sid)를 함께 넘긴다 — 실시간 알림이 끊은 세션의 연결을 끝낸다 (12단계)', async () => {
await expect(
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -95,7 +113,7 @@ describe('JwtStrategy.validate', () => {
revokedIds.add(SID);
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -106,7 +124,7 @@ describe('JwtStrategy.validate', () => {
// 다른 세션 · sid 가 없는 12단계 전의 토큰은 그대로 통과한다
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -116,7 +134,7 @@ describe('JwtStrategy.validate', () => {
).toBeUndefined();
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -128,7 +146,7 @@ describe('JwtStrategy.validate', () => {
it('리프레시 토큰을 액세스 쿠키에 넣으면 AUTH_001', async () => {
expect(
await codeOf(() =>
strategy.validate({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
validateFromCookie({ sub: 'ara.kim@arasystem.test', typ: 'refresh' }),
),
).toBe('AUTH_001');
});
@@ -136,7 +154,7 @@ describe('JwtStrategy.validate', () => {
it('제한이 실린 토큰은 세션 표의 지금 제한을 쓴다 · 유예의 시작 시각은 토큰 그대로 (13단계)', async () => {
rows.set(SID, { id: SID, restriction: 'passwordChange', revokedAt: null });
await expect(
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -155,7 +173,7 @@ describe('JwtStrategy.validate', () => {
it('제한을 푼 뒤의 옛 토큰은 제한 없음으로 — 세션 표가 지웠으면 null (마친 단계로 되돌아가지 않게)', async () => {
rows.set(SID, { id: SID, restriction: null, revokedAt: null });
const user = await strategy.validate({
const user = await validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -169,7 +187,7 @@ describe('JwtStrategy.validate', () => {
for (const payload of [{ sid: SID }, { sid: undefined }]) {
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -186,7 +204,7 @@ describe('JwtStrategy.validate', () => {
});
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -200,7 +218,7 @@ describe('JwtStrategy.validate', () => {
it('모르는 제한이 실린 토큰은 AUTH_001 — 제한을 모르고 통과시키지 않는다', async () => {
expect(
await codeOf(() =>
strategy.validate({
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
@@ -209,4 +227,99 @@ describe('JwtStrategy.validate', () => {
),
).toBe('AUTH_001');
});
it('앱 토큰(cli: app)을 Bearer 로 — 직원 정보에 client: app 을 싣는다 (지금 세션을 sid 로 안다 — 계약 1절 "앱 로그인")', async () => {
await expect(
validateFromBearer({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
sid: SID,
cli: 'app',
}),
).resolves.toStrictEqual({
email: 'ara.kim@arasystem.test',
name: '김아라',
sessionId: SID,
client: 'app',
});
});
it('웹 토큰(표시 없음)을 Bearer 로 · 앱 토큰을 쿠키로 보내면 AUTH_001 — 서로 바꿔 쓰지 못한다', async () => {
const web = {
sub: 'ara.kim@arasystem.test',
typ: 'access' as const,
name: '김아라',
sid: SID,
};
expect(await codeOf(() => validateFromBearer(web))).toBe('AUTH_001');
expect(await codeOf(() => validateFromCookie({ ...web, cli: 'app' }))).toBe(
'AUTH_001',
);
// Bearer 가 있으면 같은 요청의 쿠키는 보지 않는다 — 쿠키에 웹 토큰이 함께 와도 앱 토큰으로만 본다
expect(
await codeOf(() =>
strategy.validate(
{
headers: { authorization: 'bearer signed.bearer.token' },
cookies: { access_token: 'signed.cookie.token' },
},
web,
),
),
).toBe('AUTH_001');
});
it('모르는 표시(cli)는 어느 길로 와도 AUTH_001', async () => {
const odd = {
sub: 'ara.kim@arasystem.test',
typ: 'access' as const,
name: '김아라',
cli: 'desktop' as never,
};
expect(await codeOf(() => validateFromBearer(odd))).toBe('AUTH_001');
expect(await codeOf(() => validateFromCookie(odd))).toBe('AUTH_001');
});
it('앱 토큰에 세션 id 가 없거나 UUID 모양이 아니면 AUTH_001 — 지금 세션 · 끊은 세션을 알 수 없다 (닫힌 쪽)', async () => {
const app = {
sub: 'ara.kim@arasystem.test',
typ: 'access' as const,
name: '김아라',
cli: 'app' as const,
};
for (const sid of [undefined, 'not-a-uuid', `${SID}x`, '']) {
expect(
await codeOf(() =>
validateFromBearer(sid === undefined ? app : { ...app, sid }),
),
).toBe('AUTH_001');
}
// 웹 토큰은 그대로 — sid 가 없는 12단계 전 토큰은 만료까지 쓰인다
expect(
await codeOf(() =>
validateFromCookie({
sub: 'ara.kim@arasystem.test',
typ: 'access',
name: '김아라',
}),
),
).toBeUndefined();
});
it('Bearer 도 끊은 세션 · 제한(세션 표의 지금 값)을 웹과 같게 본다', async () => {
const app = {
sub: 'ara.kim@arasystem.test',
typ: 'access' as const,
name: '김아라',
sid: SID,
cli: 'app' as const,
};
rows.set(SID, { id: SID, restriction: 'twoFactorSetup', revokedAt: null });
await expect(
validateFromBearer({ ...app, rst: 'twoFactorSetup' }),
).resolves.toMatchObject({ client: 'app', restriction: 'twoFactorSetup' });
revokedIds.add(SID);
expect(await codeOf(() => validateFromBearer(app))).toBe('AUTH_001');
});
});
+42 -14
View File
@@ -1,31 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Strategy } from 'passport-jwt';
import { isSessionRestriction } from '@/common/decorators/allow-restricted-session.decorator';
import type { AuthenticatedUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { JWT_STRATEGY } from '@/common/guards/jwt-auth.guard';
import { readCookie } from '@/shared/utils/cookies';
import { ACCESS_TOKEN_COOKIE, JWT_ALGORITHM } from './auth.constants';
import { type AccessTokenRequest, presentedAccessToken } from './access-token';
import { JWT_ALGORITHM, SESSION_ID_PATTERN } from './auth.constants';
import { AUTH_SETTINGS, type AuthSettings } from './auth.settings';
import { AuthSessionRepository } from './auth-session.repository';
import type { AuthTokenPayload } from './auth-token.types';
import { APP_TOKEN_CLIENT, type AuthTokenPayload } from './auth-token.types';
import { RevokedSessions } from './revoked-sessions';
/** JWT 의 시각(exp)은 초 단위다 */
const MS_PER_SECOND = 1000;
/** 액세스 토큰은 Authorization 헤더가 아니라 HttpOnly 쿠키에서만 읽는다 (계약 1절) */
function accessTokenFromCookie(request: Request): string | null {
return readCookie(request, ACCESS_TOKEN_COOKIE) ?? null;
}
/**
* 액세스 토큰 검증 전략 — `JwtAuthGuard` 가 이 전략을 쓴다.
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, 그리고 **끊은 세션인지**를 확인한다
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다).
* 토큰은 웹은 HttpOnly 쿠키, 앱은 `Authorization: Bearer` 에서 읽는다 — Bearer 가 있으면 그것만 본다 (access-token.ts, 계약 1절 "앱 로그인").
* 서명·만료는 passport-jwt 가 확인하고, 여기서는 토큰 종류(typ)와 필드 모양, **길과 표시가 맞는지**(앱 토큰 `cli: 'app'` 은 Bearer 로만 ·
* 표시가 없는 웹 토큰은 쿠키로만 — 서로 바꿔 쓰면 AUTH_001), 그리고 **끊은 세션인지**를 확인한다
* (계약 5절 "로그인된 브라우저" — 토큰의 sid 를 서버 메모리의 끊은 세션 기억과 견준다, DB 를 묻지 않는다 — Bearer 도 같다).
* 13단계: 제한(`rst`)이 실린 토큰만 세션 표의 **지금 제한**을 읽어 쓴다 — 푸는 요청(비밀번호 바꾸기 · 2단계 켜기)이 세션의 제한을 지운 뒤
* 옛 토큰으로 온 요청(새로고침 · 다른 탭)이 마친 단계로 되돌아가지 않게. 제한이 없는 토큰은 지금처럼 DB 를 묻지 않는다
*/
@@ -37,14 +33,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
private readonly sessions: AuthSessionRepository,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([accessTokenFromCookie]),
jwtFromRequest: (request: AccessTokenRequest) =>
presentedAccessToken(request).token,
secretOrKey: settings.jwtSecret,
algorithms: [JWT_ALGORITHM],
ignoreExpiration: false,
// 토큰이 어느 길(쿠키 · Bearer)로 왔는지 validate 가 본다
passReqToCallback: true,
});
}
async validate(payload: AuthTokenPayload): Promise<AuthenticatedUser> {
async validate(
request: AccessTokenRequest,
payload: AuthTokenPayload,
): Promise<AuthenticatedUser> {
if (
payload.typ !== 'access' ||
typeof payload.sub !== 'string' ||
@@ -61,7 +63,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
context: { reason: 'unknown-session-restriction' },
});
}
if (payload.cli !== undefined && payload.cli !== APP_TOKEN_CLIENT) {
// 서명은 맞는데 모르는 표시 — 웹 · 앱 어느 쪽으로도 받지 않는다 (닫힌 쪽)
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-token-client' },
});
}
const isAppToken = payload.cli === APP_TOKEN_CLIENT;
if (isAppToken !== (presentedAccessToken(request).transport === 'bearer')) {
// 웹 토큰을 Bearer 로 · 앱 토큰을 쿠키로 — 서로 바꿔 쓰지 못한다 (계약 1절 "앱 로그인")
throw new BusinessException('AUTH_001', {
context: {
reason: isAppToken ? 'app-token-in-cookie' : 'web-token-as-bearer',
},
});
}
const sessionId = typeof payload.sid === 'string' ? payload.sid : null;
if (
isAppToken &&
(sessionId === null || !SESSION_ID_PATTERN.test(sessionId))
) {
// 앱 토큰은 늘 세션 id 를 싣는다(앱 1단계 뒤에만 있다) — 없거나 모양이 틀리면 지금 세션 · 끊은 세션을 알 수 없어 받지 않는다 (닫힌 쪽)
throw new BusinessException('AUTH_001', {
context: { reason: 'app-token-without-session' },
});
}
if (sessionId !== null && this.revoked.isRevoked(sessionId)) {
// 로그아웃 · 비밀번호 바꾸기 · 로그인된 브라우저에서 끊은 세션 — 클라이언트는 갱신을 한 번 시도하고(세션이 끊겨 401) 로그인 화면으로 간다
throw new BusinessException('AUTH_001', {
@@ -76,6 +102,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) {
? { accessTokenExpiresAt: payload.exp * MS_PER_SECOND }
: {}),
...(sessionId !== null ? { sessionId } : {}),
// 앱 요청 — "지금 세션"을 리프레시 쿠키 대신 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
...(isAppToken ? { client: 'app' as const } : {}),
// 제한된 세션 · 2단계 인증 필수의 유예 (13단계) — 있을 때만. 가드가 제한을 본다(세션 표의 지금 값)
...(payload.rst !== undefined
? { restriction: await this.currentRestriction(sessionId) }
+11 -7
View File
@@ -251,7 +251,7 @@ export class AccountCommands {
expired
? `1. 앱 비밀번호 — 건너뜀(비밀번호가 이미 만료된 계정이라 엔진이 막는다)${employee.appPasswords > 0 ? ` · ${employee.appPasswords}개가 남아 있다 — 직원이 새 비밀번호를 정한 뒤 다시 재설정` : ''}`
: `1. 앱 비밀번호 ${employee.appPasswords}개 지우기 (메일 프로그램은 새 앱 비밀번호로 다시 연결)`,
'2. 웹메일 로그인 모두 끊기',
'2. 웹메일 · 앱 로그인 모두 끊기',
expired
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
@@ -318,13 +318,13 @@ export class AccountCommands {
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
} catch (error) {
step(
io,
false,
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`,
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${failureLine(error)}`,
);
await fail(`세션 끊기 실패 · ${failureDetail(error)}`);
return;
@@ -367,7 +367,7 @@ export class AccountCommands {
step(
io,
false,
`비밀번호를 바꿨는지 모릅니다(웹메일 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`,
`비밀번호를 바꿨는지 모릅니다(웹메일 · 앱 로그인은 끊었습니다) — ${failureLine(error)} 알림 메일은 그대로 둡니다.`,
);
await fail(`비밀번호 바꾸기 결과 모름 · ${failureDetail(error)}`);
return;
@@ -375,7 +375,7 @@ export class AccountCommands {
step(
io,
false,
`비밀번호를 바꾸지 못했습니다(웹메일 로그인은 끊었습니다). ${failureLine(error)}`,
`비밀번호를 바꾸지 못했습니다(웹메일 · 앱 로그인은 끊었습니다). ${failureLine(error)}`,
);
if (noticeId !== null) {
const removed = await this.systemMail.remove(employee.email, noticeId);
@@ -397,7 +397,11 @@ export class AccountCommands {
const late = await this.data.revokeSessions(employee.email);
ended += late;
if (late > 0) {
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
step(
io,
true,
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
);
}
} catch (error) {
result = 'partial';
@@ -440,7 +444,7 @@ export class AccountCommands {
employee.passwordExpired
? '3. 받은편지함 알림 메일 — 건너뜀(비밀번호가 만료된 계정에는 넣을 수 없다)'
: '3. 받은편지함 알림 메일 넣기 (ARA SYSTEM)',
' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 브라우저',
' 그대로: 비밀번호 · 앱 비밀번호 · 로그인된 기기',
...(requirement.isRequired
? [
' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다',
+3 -3
View File
@@ -67,7 +67,7 @@ export function employeeLine(
return [
`${employee.email} (${employee.name})`,
` 상태 ${flags || '정상'} · 2단계 ${employee.twoFactorEnabled ? '켜짐' : '꺼짐'} · 앱 비밀번호 ${employee.appPasswords}개`,
` 마지막 웹메일 로그인 ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`,
` 마지막 로그인(웹메일 · 앱) ${timeText(lastLogin)} · 메일함 ${usageText(employee.usedBytes, employee.quotaBytes)}`,
].join('\n');
}
@@ -329,14 +329,14 @@ export class AdminCommands {
}
}
/** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 웹메일 로그인 · 메일함 사용량 (읽기만 — 기록하지 않는다) */
/** 현황 — 직원마다 잠금 · 2단계 · 앱 비밀번호 수 · 마지막 로그인(웹메일 · 앱) · 메일함 사용량 (읽기만 — 기록하지 않는다) */
async status(io: OpsIo): Promise<void> {
try {
const [employees, settings] = await Promise.all([
this.engine.listEmployees(),
this.settings.reload(),
]);
const lastLogins = await this.data.lastWebmailLogins(
const lastLogins = await this.data.lastSignIns(
employees.map((employee) => employee.email),
);
// 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다
+14 -6
View File
@@ -92,7 +92,7 @@ function footprintLines(footprint: AddressFootprint, first: number): string[] {
`${first}. 우리 DB 의 그 주소 기록 지우기 — 같은 주소를 다시 쓸 때 섞이지 않게`,
...rowsLines(footprint.rows),
...(footprint.exempt ? [' 2단계 인증 필수의 예외 주소에서 빼기'] : []),
' 웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다',
' 웹메일 · 앱 세션은 끊긴 채 두었다가 정리 작업이 지운다',
];
if (footprint.lists.length > 0) {
lines.push(
@@ -166,7 +166,7 @@ export class LockCommands {
? [`2. 자동 응답 — 제목 「${rules.autoReply.subject}」`]
: []),
'3. 로그인 권한 빼기 — 웹메일 · 메일 프로그램 · 앱 비밀번호가 모두 막힌다. 메일은 계속 받는다',
'4. 웹메일 로그인 모두 끊기',
'4. 웹메일 · 앱 로그인 모두 끊기',
...(revokeLinks ? ['5. 보낸 대용량 첨부 공개 링크 모두 끊기'] : []),
];
if (!(await confirmPlan(io, '잠금', lines))) return;
@@ -243,12 +243,16 @@ export class LockCommands {
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
notes.push(`세션 ${ended}개 끊음`);
} catch (error) {
result = 'partial';
step(io, false, `웹메일 로그인을 끊지 못했습니다. ${failureLine(error)}`);
step(
io,
false,
`웹메일 · 앱 로그인을 끊지 못했습니다. ${failureLine(error)}`,
);
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
}
if (revokeLinks) {
@@ -288,11 +292,15 @@ export class LockCommands {
step(
io,
true,
`웹메일 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`,
`웹메일 · 앱 로그인 ${ended}개는 끊었습니다(15초 안에 막힙니다) — 다시 「잠금」을 하세요.`,
);
notes.push(`세션 ${ended}개 끊음`);
} catch (error) {
step(io, false, `웹메일 로그인도 끊지 못했습니다. ${failureLine(error)}`);
step(
io,
false,
`웹메일 · 앱 로그인도 끊지 못했습니다. ${failureLine(error)}`,
);
notes.push(`세션 끊기 실패 · ${failureDetail(error)}`);
}
if (
+24 -16
View File
@@ -187,7 +187,7 @@ function renewPlan(
? `${employee.name} <${employee.email}> — 지금 이전용 ~${untilText(employee)}`
: `${employee.name} <${employee.email}> — 비밀번호가 만료된 계정`,
),
'1. 웹메일 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)',
'1. 웹메일 · 앱 로그인 모두 끊기 — 지금 비밀번호로 들어온 로그인이 이전용 비밀번호로 이어지지 않게(건 뒤 그사이 새로 들어온 것도 끊는다)',
sessionHasPassword
? '2. 이번 실행의 이전용 비밀번호(이번 실행에서 마지막으로 보인 값)를 건다 — 다시 보이지 않는다'
: '2. 이전용 비밀번호를 무작위로 만들어 건다 — 만든 뒤 한 번만 보인다. 이번 실행에서는 모두 이 값을 쓴다',
@@ -224,12 +224,12 @@ function finishPlan(
: single.appPasswords > 0
? `1. 앱 비밀번호 ${single.appPasswords}개 지우기`
: '1. 앱 비밀번호 — 없다',
'2. 웹메일 로그인 모두 끊기 (확인하려 들어간 로그인 포함)',
'2. 웹메일 · 앱 로그인 모두 끊기 (확인하려 들어간 로그인 포함)',
single === null
? '3. 이전 중 로그인 기록 지우기(위의 줄 수 — 웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게'
: `3. 이전 중 로그인 기록 ${events(single)}줄 지우기(웹메일 · IMAP · SMTP 모두) — imapsync 의 IMAP 로그인이 직원의 로그인 기록에 보이지 않게`,
'4. 직원용 임시 비밀번호 + 만료 — 첫 로그인 때 새 비밀번호를 정한다',
'5. 그사이 새로 로그인한 웹메일도 끊기',
'5. 그사이 새로 로그인한 웹메일 · 앱도 끊기',
' 받은편지함 알림 메일은 넣지 않는다',
...(twoFactor.length > 0
? [
@@ -517,17 +517,17 @@ export class MigrationCommands {
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
} catch (error) {
const line = failureLine(error);
step(
io,
false,
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
);
return notDone(
`웹메일 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`,
`웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로) — ${line}`,
`${from} · 세션 끊기 실패 · ${failureDetail(error)}`,
);
}
@@ -581,7 +581,11 @@ export class MigrationCommands {
const late = await this.data.revokeSessions(employee.email);
ended += late;
if (late > 0) {
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
step(
io,
true,
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
);
}
} catch (error) {
result = 'partial';
@@ -589,10 +593,10 @@ export class MigrationCommands {
step(
io,
false,
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
`그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다. ${line}`,
);
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
problem = `그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다 — ${line}`;
}
await record(
result,
@@ -712,17 +716,17 @@ export class MigrationCommands {
step(
io,
true,
`웹메일 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
`웹메일 · 앱 로그인 ${ended}개를 끊었습니다(15초 안에 막힙니다).`,
);
} catch (error) {
const line = failureLine(error);
step(
io,
false,
`웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
`웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다. ${line}`,
);
return fail(
`웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
`웹메일 · 앱 로그인을 끊지 못했습니다(이전용 비밀번호 그대로) — ${line}`,
`세션 끊기 실패 · ${failureDetail(error)}`,
);
}
@@ -780,13 +784,17 @@ export class MigrationCommands {
'직원용 임시 비밀번호와 만료를 걸었습니다 — 이전용 비밀번호는 더 쓰지 못합니다.',
);
// ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일
// ⑤ 그사이(②와 ④ 사이) 이전용 비밀번호로 새로 로그인한 웹메일 · 앱
let result: 'success' | 'partial' = 'success';
try {
const late = await this.data.revokeSessions(employee.email);
ended += late;
if (late > 0) {
step(io, true, `그사이 새로 로그인한 웹메일 ${late}개도 끊었습니다.`);
step(
io,
true,
`그사이 새로 로그인한 웹메일 · 앱 ${late}개도 끊었습니다.`,
);
}
} catch (error) {
result = 'partial';
@@ -794,10 +802,10 @@ export class MigrationCommands {
step(
io,
false,
`그사이 새로 로그인한 웹메일을 확인하지 못했습니다. ${line}`,
`그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다. ${line}`,
);
notes.push(`세션 다시 끊기 실패 · ${failureDetail(error)}`);
row.problem = `그사이 새로 로그인한 웹메일을 확인하지 못했습니다 — ${line}`;
row.problem = `그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다 — ${line}`;
}
notes.push(`세션 ${ended}개 끊음`);
await this.audit.record({
+16 -14
View File
@@ -310,7 +310,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
isTwoFactorExempt: jest.Mock;
changeTwoFactorExempt: jest.Mock;
purgeAddress: jest.Mock;
lastWebmailLogins: jest.Mock;
lastSignIns: jest.Mock;
deleteLoginEventsOf: jest.Mock;
countLoginEventsOf: jest.Mock;
lastPasswordSetIsMigration: jest.Mock;
@@ -425,7 +425,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
filesLeftForCleanup: 0,
});
}),
lastWebmailLogins: jest.fn().mockResolvedValue(new Map()),
lastSignIns: jest.fn().mockResolvedValue(new Map()),
deleteLoginEventsOf: jest.fn((email: string) => {
engine.calls.push(`deleteLoginEvents:${email}`);
return Promise.resolve(12);
@@ -611,7 +611,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
1,
);
expect(io.output).toContain(
'그사이 새로 로그인한 웹메일 1개도 끊었습니다',
'그사이 새로 로그인한 웹메일 · 앱 1개도 끊었습니다',
);
expect(audit.entries[0]).toMatchObject({
command: 'password-reset',
@@ -1006,7 +1006,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
`공용 주소 1개의 구성원에서 빼기 — info@${DOMAIN}`,
);
expect(no.output).toContain(
'웹메일 세션은 끊긴 채 두었다가 정리 작업이 지운다',
'웹메일 · 앱 세션은 끊긴 채 두었다가 정리 작업이 지운다',
);
expect(order()).toEqual([`find:${ARA}`]);
@@ -1250,13 +1250,15 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
});
it('현황 — 직원마다 한 줄 묶음과 정책 · 연락처 (읽기만 — 기록하지 않는다)', async () => {
data.lastWebmailLogins.mockResolvedValueOnce(
data.lastSignIns.mockResolvedValueOnce(
new Map([[ARA, new Date('2026-10-30T06:20:00Z')]]),
);
const io = new ScriptedIo([]);
await admin.status(io);
expect(io.output).toContain(`${ARA} (김아라)`);
expect(io.output).toContain('2026년 10월 30일 (금) 오후 3:20');
expect(io.output).toContain(
'마지막 로그인(웹메일 · 앱) 2026년 10월 30일 (금) 오후 3:20',
);
expect(io.output).toContain('2단계 인증 필수 정책 꺼짐');
expect(audit.entries).toEqual([]);
});
@@ -1511,9 +1513,9 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
expect(io.output).toContain(
'지금 이전용 ~2026년 12월 1일 (화) 오후 3:00',
);
expect(io.output).toContain('1. 웹메일 로그인 모두 끊기');
expect(io.output).toContain('1. 웹메일 · 앱 로그인 모두 끊기');
expect(io.output).toContain(
'그사이 새로 로그인한 웹메일 1개도 끊었습니다',
'그사이 새로 로그인한 웹메일 · 앱 1개도 끊었습니다',
);
expect(linesWith(io, password)).toBe(1);
expect(audit.entries).toEqual([
@@ -1539,7 +1541,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
await migration.renewPasswords(io, OPERATOR);
expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]);
expect(io.output).toContain(
'웹메일 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다',
'웹메일 · 앱 로그인을 끊지 못해 멈춥니다 — 비밀번호는 그대로입니다',
);
expect(audit.entries).toHaveLength(1);
expect(audit.entries[0]).toMatchObject({
@@ -1563,7 +1565,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
).toEqual(['setMigrationPassword:acc-kim']);
expect(io.output).toContain('걸었음 1명 · 못 함 1명');
expect(io.output).toContain(
`${HONG} (홍길동) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`,
`${HONG} (홍길동) — [못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로)`,
);
});
@@ -1844,7 +1846,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
expect(order()).toEqual([`find:${HONG}`, `find:${HONG}`]);
expect(engine.resetPasswords).toEqual([]);
expect(sessions.output).toContain(
'[못 함] 웹메일 로그인을 끊지 못했습니다(이전용 비밀번호 그대로)',
'[못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(이전용 비밀번호 그대로)',
);
expect(audit.entries.map((entry) => entry.result)).toEqual([
'failure',
@@ -1865,7 +1867,7 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
const [password] = engine.resetPasswords;
expect(linesWith(io, password)).toBe(1);
expect(io.output).toContain(
'참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다',
'참고: 그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다',
);
expect(io.output).toContain('직원용 임시 비밀번호 1명 · 못 함 0명');
expect(audit.entries[0]).toMatchObject({ result: 'partial' });
@@ -2545,10 +2547,10 @@ describe('운영 도구 명령 (13단계 — 계약 6절 "운영 도구")', () =
).toEqual(['setMigrationPassword:acc-hong']);
expect(io.output).toContain('걸었음 1명 · 못 함 1명');
expect(io.output).toContain(
`${HONG} (홍길동) — 참고: 그사이 새로 로그인한 웹메일을 확인하지 못했습니다`,
`${HONG} (홍길동) — 참고: 그사이 새로 로그인한 웹메일 · 앱을 확인하지 못했습니다`,
);
expect(io.output).toContain(
`${KIM} (김철수) — [못 함] 웹메일 로그인을 끊지 못했습니다(비밀번호 그대로)`,
`${KIM} (김철수) — [못 함] 웹메일 · 앱 로그인을 끊지 못했습니다(비밀번호 그대로)`,
);
expect(io.output).toContain(
'못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.',
+18 -9
View File
@@ -3,7 +3,10 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { type DataSource, type EntityManager, In } from 'typeorm';
import { AppPasswordUsageEntity } from '@/modules/account-security/app-password-usage.entity';
import { LoginEventEntity } from '@/modules/account-security/login-event.entity';
import {
LoginEventEntity,
type SignInMethod,
} from '@/modules/account-security/login-event.entity';
import { TwoFactorSettingEntity } from '@/modules/account-security/two-factor-setting.entity';
import { AuthSessionRepository } from '@/modules/auth/auth-session.repository';
import { ShareLinkEntity } from '@/modules/file-sharing/share-link.entity';
@@ -32,7 +35,7 @@ import {
/**
* 그 주소로 남은 우리 DB 의 줄 수 — 표마다 (퇴사 마무리 · 입사 때 겹침 확인).
* 웹메일 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절).
* 웹메일 · 앱 세션은 세지도 지우지도 않는다 — 끊은 세션 행은 서버가 15초 동기화로 읽어야 하므로 끝난 세션 정리 cron 이 지운다(계약 6절).
* 끊긴 행은 로그인된 브라우저 목록에 보이지 않고 쓸 수도 없다
*/
export interface AddressRows {
@@ -122,7 +125,7 @@ export function exemptWithout(
}
/**
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 웹메일 로그인 · 퇴사 마무리의 주소 정리 ·
* 운영 도구의 DB 일 (13단계 — 계약 6절 "운영 도구") — 세션 끊기 · 2단계 기록 지우기 · 현황의 마지막 로그인(웹메일 · 앱) · 퇴사 마무리의 주소 정리 ·
* 메일 이전 마치기의 이전 중 로그인 기록 지우기.
* 우리 DB 는 주소로 직원을 가린다 — 지운 주소를 새 사람이 다시 쓰면 옛 로그인 기록 · 서명 · 설정 · 2단계 기록 · 2단계 필수 예외가 그대로 남으므로
* 퇴사 마무리가 그 주소의 줄을 지운다. 세션 행은 끊기만 한다(정리 cron 이 지운다). 실행 기록(ops_audit_log)은 지우지 않는다.
@@ -139,7 +142,7 @@ export class OpsDataService {
) {}
/**
* 그 직원의 웹메일 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts)
* 그 직원의 웹메일 · 앱 세션을 모두 끊는다 → 끊은 수. 서버는 끊은 세션을 15초 안에 DB 에서 읽어 막는다(revoked-sessions.ts — Bearer 도)
*/
revokeSessions(email: string): Promise<number> {
return this.sessions.revokeAllOf(
@@ -157,16 +160,22 @@ export class OpsDataService {
});
}
/** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */
async lastWebmailLogins(
emails: readonly string[],
): Promise<Map<string, Date>> {
/**
* 마지막으로 웹메일 · 앱에 로그인한 때 (성공한 웹메일 · 앱 로그인 기록 — 90일 보존 안). 현황에만 쓴다 —
* 앱만 쓰는 직원이 로그인한 적 없어 보이지 않게 앱(계약 1절 "앱 로그인")도 센다. 메일 프로그램(IMAP · SMTP)은 세지 않는다
*/
async lastSignIns(emails: readonly string[]): Promise<Map<string, Date>> {
if (emails.length === 0) return new Map();
const methods: SignInMethod[] = ['webmail', 'app'];
const rows = await this.dataSource.manager
.createQueryBuilder(LoginEventEntity, 'event')
.select('event.user_email', 'email')
.addSelect('MAX(event.occurred_at)', 'last')
.where({ userEmail: In([...emails]), method: 'webmail', isSuccess: true })
.where({
userEmail: In([...emails]),
method: In(methods),
isSuccess: true,
})
.groupBy('event.user_email')
.getRawMany<{ email: string; last: Date | string }>();
return new Map(
@@ -55,7 +55,7 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
'',
'시각: 2026년 10월 30일 (금) 오후 3:20',
'바뀐 것: 비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
'로그아웃: 모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다',
'로그아웃: 모든 기기의 웹메일과 앱에서 로그아웃되었습니다',
'앱 비밀번호: 모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
'',
'할 일',
@@ -80,7 +80,7 @@ describe('받은편지함 알림 메일의 글 (13단계 — 디자인 README 13
'김아라 님, 휴대폰을 잃어버렸거나 인증 앱을 쓸 수 없다고 알려 주셔서 관리자가 2단계 인증을 껐습니다.',
);
expect(notice.text).toContain(
'그대로인 것: 비밀번호, 앱 비밀번호, 로그인된 브라우저',
'그대로인 것: 비밀번호, 앱 비밀번호, 로그인된 기기',
);
expect(notice.html).toContain(
'웹메일의 <strong>[설정] → [2단계 인증]</strong>에서 다시 켜 주세요.',
@@ -158,7 +158,7 @@ export function passwordResetNotice(
'바뀐 것',
'비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다',
],
['로그아웃', '모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다'],
['로그아웃', '모든 기기의 웹메일과 앱에서 로그아웃되었습니다'],
[
'앱 비밀번호',
'모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다',
@@ -190,7 +190,7 @@ export function twoFactorResetNotice(
rows: [
['시각', formatKstDateTime(at)],
['바뀐 것', '2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다'],
['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 브라우저'],
['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 기기'],
],
todo: [
{ text: '새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 ' },
+1 -1
View File
@@ -19,7 +19,7 @@ import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
// (인증 없는 예제다. 보호가 필요하면 @UseGuards(JwtAuthGuard) + @ApiCookieAuth() — 인증은 쿠키뿐이다)
// (인증 없는 예제다. 보호가 필요하면 @UseGuards(JwtAuthGuard) + @ApiCookieAuth() — 웹은 쿠키, 앱은 Bearer 를 가드가 받고 문서의 Bearer 는 main.ts 가 붙인다)
@ApiTags('User')
@Controller('users')
export class UserController {