generated from ttipo/project-base
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:
@@ -72,7 +72,7 @@ export function ApiErrorResponse(code: ErrorCode, description?: string) {
|
||||
|
||||
/** AUTH_001 의 기본 설명 — 로그인이 필요한 API */
|
||||
const SESSION_REQUIRED_DESCRIPTION =
|
||||
'로그인 필요 (쿠키 없음 · 만료 · 끊은 세션) — 클라이언트는 /auth/refresh 를 한 번 시도한다';
|
||||
'로그인 필요 (쿠키 · 앱의 Bearer 토큰 없음 · 만료 · 끊은 세션 · 웹 · 앱 토큰을 바꿔 씀) — 클라이언트는 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다';
|
||||
|
||||
/**
|
||||
* 로그인이 필요한 API 의 공통 실패 (13단계 — 계약 6절) — 401 AUTH_001(로그인 필요) + 403 AUTH_010(제한된 세션 — 비밀번호 바꾸기 · 2단계 켜기 전).
|
||||
|
||||
@@ -4,6 +4,12 @@ import type { Request } from 'express';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import type { SessionRestriction } from './allow-restricted-session.decorator';
|
||||
|
||||
/**
|
||||
* 로그인 세션을 연 클라이언트 (계약 1절 "앱 로그인") — 웹(브라우저 · 쿠키) · 앱(모바일 · Bearer).
|
||||
* 세션 표의 `client` 칸과 같은 값이다. 세션 규칙은 같고 토큰을 나르는 길만 다르다
|
||||
*/
|
||||
export type SessionClient = 'web' | 'app';
|
||||
|
||||
/** 인증된 직원 — 액세스 토큰에서 복원한 값 (요청마다 메일 엔진에 묻지 않는다) */
|
||||
export interface AuthenticatedUser {
|
||||
/** 직원 메일 주소 (= 메일 계정) */
|
||||
@@ -20,6 +26,11 @@ export interface AuthenticatedUser {
|
||||
* 실시간 알림이 끊은 세션의 연결을 다음 ping 때 끝내는 데 쓴다 (계약 5절 "로그인된 브라우저")
|
||||
*/
|
||||
readonly sessionId?: string;
|
||||
/**
|
||||
* 앱 토큰(`Authorization: Bearer` — 표시 `cli: 'app'`)으로 들어온 요청이면 'app' — 가드를 거친 요청에만 있다. 웹(쿠키)이면 없다.
|
||||
* 앱 요청은 리프레시 쿠키가 없으므로 "지금 세션"을 액세스 토큰의 sid 로 안다 (계약 1절 "앱 로그인 › 지금 세션")
|
||||
*/
|
||||
readonly client?: SessionClient;
|
||||
/**
|
||||
* 제한된 세션이면 그 제한 (계약 6절 "제한된 세션" — 13단계) — 액세스 토큰의 `rst`. 없으면(null · 없음) 제한이 없다.
|
||||
* JwtAuthGuard 가 허용한 핸들러(@AllowRestrictedSession) 밖이면 403 AUTH_010 으로 막는다
|
||||
|
||||
@@ -10,15 +10,16 @@ import { BusinessException } from '@/common/errors/business.exception';
|
||||
export const JWT_STRATEGY = 'jwt';
|
||||
|
||||
/**
|
||||
* 액세스 토큰 쿠키로 보호하는 가드.
|
||||
* 액세스 토큰으로 보호하는 가드 — 웹은 HttpOnly 쿠키, 앱(모바일)은 `Authorization: Bearer` (계약 1절 "앱 로그인").
|
||||
* 앱 토큰(표시 cli: app)은 Bearer 로만, 웹 토큰은 쿠키로만 받는다 — 서로 바꿔 쓰면 AUTH_001 (modules/auth/jwt.strategy.ts).
|
||||
* 토큰이 없거나·만료됐거나·위조됐으면 원인을 구분하지 않고 AUTH_001 로 응답한다.
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 /auth/refresh 를 한 번 시도한다)
|
||||
* (구체적인 사유는 로그 context 에만 남긴다 — 클라이언트는 AUTH_001 이면 갱신(웹 /auth/refresh · 앱 /auth/app/refresh)을 한 번 시도한다)
|
||||
*
|
||||
* 제한된 세션(토큰의 `rst` — 계약 6절 "제한된 세션")은 `@AllowRestrictedSession()` 을 단 핸들러 · 컨트롤러만 통과한다 —
|
||||
* 그 밖은 403 AUTH_010 (실시간 알림 `/api/mail-events` 포함). 허용 목록은 가드를 거는 쪽이 아니라 핸들러가 정한다 —
|
||||
* 새 보호 라우트는 달지 않으면 저절로 막힌다(닫힌 쪽).
|
||||
*
|
||||
* 사용 예:
|
||||
* 사용 예 (Swagger 에는 @ApiCookieAuth() 만 적는다 — 앱의 Bearer 는 main.ts 가 "또는"으로 붙인다):
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* @ApiCookieAuth()
|
||||
*/
|
||||
|
||||
+19
-2
@@ -13,6 +13,7 @@ import { configureApp, createHttpAdapter } from '@/app.setup';
|
||||
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
|
||||
import { winstonConfig } from '@/shared/logger/winston.config';
|
||||
import { parsePositiveInt } from '@/shared/utils/env';
|
||||
import { addAlternativeSecurity } from '@/shared/utils/openapi-security';
|
||||
import {
|
||||
buildAllowedOrigins,
|
||||
normalizeOrigin,
|
||||
@@ -21,6 +22,10 @@ import {
|
||||
/** PORT 가 없거나 비었을 때 듣는 포트 */
|
||||
const DEFAULT_PORT = 3000;
|
||||
|
||||
/** Swagger 인증 이름 — `@ApiCookieAuth()` · `@ApiBearerAuth()` 의 기본 이름과 같다 */
|
||||
const SWAGGER_COOKIE_AUTH = 'cookie';
|
||||
const SWAGGER_BEARER_AUTH = 'bearer';
|
||||
|
||||
async function bootstrap() {
|
||||
// 어댑터는 createHttpAdapter — 라우팅이 대소문자를 가린다(어댑터를 만들 때 켜야 한다 — app.setup.ts). e2e 도 같은 함수를 쓴다
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
@@ -72,14 +77,26 @@ async function bootstrap() {
|
||||
// 라우트 단위 제어(@Throttle / @SkipThrottle)가 가능하고, 한도 초과는 REQ_001(429)로 응답된다.
|
||||
// (전역 예외 필터가 429 → REQ_001 로 매핑한다 — .claude/rules/error-codes.md)
|
||||
|
||||
// 4. Swagger API 문서 설정 — 인증은 HttpOnly 쿠키(access_token)뿐이다 (Authorization 헤더는 읽지 않는다)
|
||||
// 4. Swagger API 문서 설정 — 인증은 웹은 HttpOnly 쿠키(access_token), 앱은 Authorization: Bearer (앱 로그인이 준 토큰만 — 계약 1절 "앱 로그인").
|
||||
// 보호 라우트는 @ApiCookieAuth() 만 적는다 — 가드(JwtAuthGuard)가 두 길을 다 받으므로 Bearer 는 여기서 "또는"으로 한 번에 붙인다
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Backend API Documentation')
|
||||
.setDescription('The API documentation conforming to the global guidelines')
|
||||
.setVersion('1.0')
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE)
|
||||
.addCookieAuth(ACCESS_TOKEN_COOKIE, { type: 'apiKey' }, SWAGGER_COOKIE_AUTH)
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description:
|
||||
'앱(모바일) 전용 — POST /api/auth/app/login · refresh 가 준 액세스 토큰만 받는다(웹 토큰은 401). 웹은 쿠키를 쓴다',
|
||||
},
|
||||
SWAGGER_BEARER_AUTH,
|
||||
)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
addAlternativeSecurity(document, SWAGGER_COOKIE_AUTH, SWAGGER_BEARER_AUTH);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
|
||||
// 5. Graceful shutdown — SIGTERM 수신 시 새 요청을 받지 않고
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* 앱 전용 토큰 경로 (docs/mail/api-contract.md 1절 "앱 로그인" — 앱 1단계).
|
||||
* - auth_sessions.client: 세션을 연 클라이언트 — web(브라우저 · 쿠키) · app(모바일 앱 · Bearer). 기존 세션은 기본값 'web'.
|
||||
* DB 기본값을 두어 이 칸을 모르는 코드(다른 브랜치)가 넣는 행도 웹 세션으로 들어간다
|
||||
* 로그인 기록의 방식 `app` 은 칸을 바꾸지 않는다 — login_events.method 는 제약 없는 varchar(32) 다.
|
||||
* 기존 표의 칸 · 행은 지우지 않는다 — 칸을 더하기만 한다.
|
||||
* (엔티티: modules/auth/auth-session.entity.ts — migration:generate 의 결과에서 이 칸의 두 문장만 옮겼다.
|
||||
* 개발 DB 에 다른 브랜치의 칸(mail_settings.group_by_conversation)이 있어 generate 가 그 칸을 지우는 문장도 냈다 — 넣지 않는다)
|
||||
*/
|
||||
export class AddAppSessionClient1793933074677 implements MigrationInterface {
|
||||
name = 'AddAppSessionClient1793933074677';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "auth_sessions" ADD "client" character varying(16) NOT NULL DEFAULT 'web'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "client"`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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단계 인증은 그대로 남는다). ' +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
// 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
'못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.',
|
||||
|
||||
@@ -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: '새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 ' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
|
||||
import { addAlternativeSecurity } from './openapi-security';
|
||||
|
||||
/** 작업 셋 — 쿠키 인증 · 이미 Bearer 도 있음 · 인증 없음 */
|
||||
function documentOf(): OpenAPIObject {
|
||||
return {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 't', version: '1' },
|
||||
paths: {
|
||||
'/api/mailboxes': {
|
||||
get: { responses: {}, security: [{ cookie: [] }] },
|
||||
post: { responses: {}, security: [{ cookie: [] }, { bearer: [] }] },
|
||||
},
|
||||
'/api/auth/app/login': { post: { responses: {} } },
|
||||
'/api/auth/app/logout': {
|
||||
post: { responses: {}, security: [{ bearer: [] }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('addAlternativeSecurity (Swagger — 쿠키 인증에 앱의 Bearer 를 "또는"으로)', () => {
|
||||
it('쿠키 인증이 적힌 작업에만 Bearer 를 더한다 — 이미 있거나 인증이 없는 작업은 그대로', () => {
|
||||
const document = documentOf();
|
||||
|
||||
expect(addAlternativeSecurity(document, 'cookie', 'bearer')).toBe(1);
|
||||
|
||||
expect(document.paths['/api/mailboxes'].get?.security).toEqual([
|
||||
{ cookie: [] },
|
||||
{ bearer: [] },
|
||||
]);
|
||||
expect(document.paths['/api/mailboxes'].post?.security).toEqual([
|
||||
{ cookie: [] },
|
||||
{ bearer: [] },
|
||||
]);
|
||||
expect(
|
||||
document.paths['/api/auth/app/login'].post?.security,
|
||||
).toBeUndefined();
|
||||
expect(document.paths['/api/auth/app/logout'].post?.security).toEqual([
|
||||
{ bearer: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('두 번 불러도 겹쳐 더하지 않는다', () => {
|
||||
const document = documentOf();
|
||||
addAlternativeSecurity(document, 'cookie', 'bearer');
|
||||
|
||||
expect(addAlternativeSecurity(document, 'cookie', 'bearer')).toBe(0);
|
||||
expect(document.paths['/api/mailboxes'].get?.security).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||
|
||||
/** OpenAPI 의 작업(operation)이 놓이는 HTTP 메서드 칸 */
|
||||
const HTTP_METHODS = [
|
||||
'get',
|
||||
'put',
|
||||
'post',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'patch',
|
||||
'trace',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 문서의 작업마다 `existing` 인증이 적혀 있으면 `alternative` 인증을 "또는"으로 더한다 → 더한 작업 수 (문서를 그 자리에서 고친다).
|
||||
* OpenAPI 의 security 배열은 항목 하나만 맞아도 되는 뜻이다 — `[{ cookie: [] }, { bearer: [] }]` = 쿠키 또는 Bearer.
|
||||
*
|
||||
* 쓰는 곳: main.ts — 보호 라우트는 `@ApiCookieAuth()` 만 적고, 가드(JwtAuthGuard)가 앱의 Bearer 도 받으므로
|
||||
* 문서에 Bearer 를 한 곳에서 붙인다(계약 1절 "앱 로그인"). 컨트롤러마다 적으면 새 라우트가 빠뜨리기 쉽다.
|
||||
* 이미 `alternative` 가 있는 작업 · 인증이 없는 작업(로그인 · 공개 경로)은 그대로 둔다
|
||||
*/
|
||||
export function addAlternativeSecurity(
|
||||
document: OpenAPIObject,
|
||||
existing: string,
|
||||
alternative: string,
|
||||
): number {
|
||||
let added = 0;
|
||||
for (const pathItem of Object.values(document.paths)) {
|
||||
for (const method of HTTP_METHODS) {
|
||||
const security = pathItem[method]?.security;
|
||||
if (!security) continue;
|
||||
const has = (scheme: string) =>
|
||||
security.some((requirement) => scheme in requirement);
|
||||
if (has(existing) && !has(alternative)) {
|
||||
security.push({ [alternative]: [] });
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
USER_AGENT_MAX_LENGTH,
|
||||
describeAppUserAgent,
|
||||
describeUserAgent,
|
||||
truncateUserAgent,
|
||||
} from './user-agent';
|
||||
@@ -90,6 +91,13 @@ describe('describeUserAgent', () => {
|
||||
expect(describeUserAgent(ua)).toEqual({ browser, os });
|
||||
});
|
||||
|
||||
it('웹(브라우저)의 짧은 이름은 앱 꼴의 원문에도 앱 이름을 주지 않는다 — 꾸민 웹 세션이 직원의 앱처럼 보이지 않게 (브라우저 null)', () => {
|
||||
expect(
|
||||
describeUserAgent('ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)'),
|
||||
).toEqual({ browser: null, os: 'Android' });
|
||||
expect(describeUserAgent('ARASYSTEM-App/1.0.0')).toBeNull();
|
||||
});
|
||||
|
||||
it('원문이 없거나(12단계 전의 세션 · 메일 프로그램) 둘 다 모르면 null', () => {
|
||||
expect(describeUserAgent(null)).toBeNull();
|
||||
expect(describeUserAgent(undefined)).toBeNull();
|
||||
@@ -114,3 +122,39 @@ describe('truncateUserAgent', () => {
|
||||
expect(truncateUserAgent(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeAppUserAgent (앱 세션 · 앱 로그인 기록 — 서버가 앱으로 정한 것만, 계약 1절 "앱 로그인")', () => {
|
||||
it('ARA SYSTEM 앱 · 첫 괄호 칸의 운영체제 — 기종 이름은 보지 않는다, 앱이 기기 정보를 못 읽으면 소문자로 온다', () => {
|
||||
expect(
|
||||
describeAppUserAgent('ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)'),
|
||||
).toEqual({ browser: 'ARA SYSTEM 앱', os: 'Android' });
|
||||
expect(
|
||||
describeAppUserAgent('ARASYSTEM-App/1.0.0 (iOS 18.6; iPhone16,2)'),
|
||||
).toEqual({ browser: 'ARA SYSTEM 앱', os: 'iOS' });
|
||||
expect(
|
||||
describeAppUserAgent('ARASYSTEM-App/1.0.0 (Android 15; Windows Edition)'),
|
||||
).toEqual({ browser: 'ARA SYSTEM 앱', os: 'Android' });
|
||||
expect(describeAppUserAgent('ARASYSTEM-App/1.0.0 (android)')).toEqual({
|
||||
browser: 'ARA SYSTEM 앱',
|
||||
os: 'Android',
|
||||
});
|
||||
});
|
||||
|
||||
it('이름은 늘 붙이고, 원문이 없거나 앱 꼴이 아니거나 운영체제를 모르면 운영체제만 null', () => {
|
||||
for (const ua of [
|
||||
null,
|
||||
undefined,
|
||||
' ',
|
||||
'ARASYSTEM-App/1.0.0',
|
||||
'ARASYSTEM-App (fuchsia)',
|
||||
// 앱 꼴이 아니면 괄호를 보지 않는다 — 브라우저 원문의 운영체제를 지어 붙이지 않는다
|
||||
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36',
|
||||
'ARASYSTEM-Application/1.0 (Android 15)',
|
||||
]) {
|
||||
expect(describeAppUserAgent(ua)).toEqual({
|
||||
browser: 'ARA SYSTEM 앱',
|
||||
os: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* User-Agent → 짧은 이름 (순수 함수) — 로그인된 브라우저 · 로그인 기록의 `device` (계약 5절 "로그인된 브라우저").
|
||||
* 이름 목록은 계약 그대로다 — 브라우저 `Edge` · `Chrome` · `Firefox` · `Safari` · `Whale` · `Samsung Internet`,
|
||||
* 운영체제 `Windows` · `macOS` · `iOS` · `Android` · `Linux` · `ChromeOS`. 모르면 null(목록에 없는 이름을 지어내지 않는다).
|
||||
* 우리 모바일 앱(계약 1절 "앱 로그인")은 `ARA SYSTEM 앱` · 그 운영체제다 — **서버가 앱으로 정한 것(세션의 client · 로그인 기록의 method 가 app)에만**
|
||||
* `describeAppUserAgent` 로 붙인다. User-Agent 는 누구나 꾸밀 수 있으므로 웹 세션이 앱 꼴(`ARASYSTEM-App/…`)을 보내도 앱 이름을 주지 않는다(브라우저 null).
|
||||
*
|
||||
* 서버에는 원문(앞 512자 — USER_AGENT_MAX_LENGTH)을 두고 응답할 때 이 함수로 바꾼다 — 판정을 고치면 지난 기록에도 맞게 보인다.
|
||||
* 낱말 찾기(includes)만 쓴다 — 정규식 되돌아가기 비용이 없고 입력 길이에 비례한다.
|
||||
@@ -16,7 +18,8 @@ export type BrowserName =
|
||||
| 'Firefox'
|
||||
| 'Safari'
|
||||
| 'Whale'
|
||||
| 'Samsung Internet';
|
||||
| 'Samsung Internet'
|
||||
| typeof APP_BROWSER_NAME;
|
||||
|
||||
export type OsName =
|
||||
| 'Windows'
|
||||
@@ -31,6 +34,17 @@ export interface DeviceNames {
|
||||
readonly os: OsName | null;
|
||||
}
|
||||
|
||||
/** 우리 모바일 앱이 보내는 User-Agent 의 제품 이름 — flutter `kUserAgentProduct` 와 짝 (계약 1절 "앱 로그인") */
|
||||
export const APP_USER_AGENT_PRODUCT = 'ARASYSTEM-App';
|
||||
/** 우리 모바일 앱의 화면 이름 — 앱 세션 · 앱 로그인 기록에만 "ARA SYSTEM 앱 · Android" 꼴로 보인다 */
|
||||
export const APP_BROWSER_NAME = 'ARA SYSTEM 앱';
|
||||
|
||||
/** 앱 User-Agent 의 첫 괄호 칸(`Android 15` · `iOS 18.6`)의 운영체제 — 앱이 기기 정보를 못 읽으면 소문자(`android`)로 온다 */
|
||||
const APP_OS_PREFIXES: readonly (readonly [prefix: string, os: OsName])[] = [
|
||||
['android', 'Android'],
|
||||
['ios', 'iOS'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Chrome 엔진을 쓰지만 목록에 없는 브라우저의 표시 — 이것이 있으면 Chrome 이라고 부르지 않는다(모른다 — null).
|
||||
* Whale · Samsung · Edge 는 앞에서 먼저 알아본다
|
||||
@@ -51,6 +65,26 @@ export function truncateUserAgent(
|
||||
return trimmed === '' ? null : trimmed.slice(0, USER_AGENT_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/** 우리 모바일 앱의 User-Agent 인가 — 제품 이름으로 시작하고 바로 뒤가 끝 · `/` · 공백 */
|
||||
function isAppUserAgent(ua: string): boolean {
|
||||
if (!ua.startsWith(APP_USER_AGENT_PRODUCT)) return false;
|
||||
const next = ua.charAt(APP_USER_AGENT_PRODUCT.length);
|
||||
return next === '' || next === '/' || next === ' ';
|
||||
}
|
||||
|
||||
/** 앱 User-Agent 의 운영체제 — 첫 괄호 안의 첫 칸(`;` 앞)으로 본다. 모르면 null */
|
||||
function appOsOf(ua: string): OsName | null {
|
||||
const open = ua.indexOf('(');
|
||||
if (open === -1) return null;
|
||||
const rest = ua.slice(open + 1);
|
||||
const end = rest.search(/[;)]/);
|
||||
const platform = (end === -1 ? rest : rest.slice(0, end))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const found = APP_OS_PREFIXES.find(([prefix]) => platform.startsWith(prefix));
|
||||
return found ? found[1] : null;
|
||||
}
|
||||
|
||||
function browserOf(ua: string): BrowserName | null {
|
||||
// 차례가 중요하다 — 앞의 것들은 모두 "Chrome/" · "Safari/" 도 함께 적는다
|
||||
if (ua.includes('Whale/')) return 'Whale';
|
||||
@@ -87,8 +121,9 @@ function osOf(ua: string): OsName | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문 → 짧은 이름. 원문이 없거나(12단계 전의 세션 · 메일 프로그램) 둘 다 모르면 null —
|
||||
* 화면은 "알 수 없는 브라우저"로 보인다. 원문은 앞 USER_AGENT_MAX_LENGTH 자만 본다
|
||||
* 웹(브라우저)의 원문 → 짧은 이름. 원문이 없거나(12단계 전의 세션 · 메일 프로그램) 둘 다 모르면 null —
|
||||
* 화면은 "알 수 없는 브라우저"로 보인다. 원문은 앞 USER_AGENT_MAX_LENGTH 자만 본다.
|
||||
* 앱 꼴의 원문이어도 앱 이름을 주지 않는다 — 목록에 없는 브라우저(null)다
|
||||
*/
|
||||
export function describeUserAgent(
|
||||
userAgent: string | null | undefined,
|
||||
@@ -99,3 +134,17 @@ export function describeUserAgent(
|
||||
const os = osOf(ua);
|
||||
return browser === null && os === null ? null : { browser, os };
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 세션 · 앱 로그인 기록의 원문 → `ARA SYSTEM 앱` · 운영체제 (부르는 쪽이 세션의 client · 기록의 method 로 앱인지 정한다).
|
||||
* 이름은 서버가 아는 것이라 늘 붙인다. 운영체제는 앱 꼴의 원문일 때 첫 괄호 칸으로만 본다 — 원문이 없거나 앱 꼴이 아니거나 모르면 null
|
||||
*/
|
||||
export function describeAppUserAgent(
|
||||
userAgent: string | null | undefined,
|
||||
): DeviceNames {
|
||||
const ua = truncateUserAgent(userAgent);
|
||||
return {
|
||||
browser: APP_BROWSER_NAME,
|
||||
os: ua !== null && isAppUserAgent(ua) ? appOsOf(ua) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ACCOUNT_SECURITY_SETTINGS,
|
||||
type AccountSecuritySettings,
|
||||
} from '@/modules/account-security/account-security.settings';
|
||||
import { MAX_REFRESH_TOKEN_LENGTH } from '@/modules/auth/auth.constants';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
||||
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
|
||||
import { DirectoryService } from '@/modules/contacts/directory.service';
|
||||
@@ -7887,6 +7888,584 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('앱 1단계 — 앱 전용 토큰 경로 (/api/auth/app/* · Bearer)', () => {
|
||||
const APP_UA = 'ARASYSTEM-App/1.0.0 (Android 15; SM-S928N)';
|
||||
|
||||
interface AppTokens {
|
||||
accessToken: string;
|
||||
accessExpiresAt: string;
|
||||
refreshToken: string;
|
||||
refreshExpiresAt: string;
|
||||
}
|
||||
interface AppSession {
|
||||
status?: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
restriction: string | null;
|
||||
twoFactorDeadline: string | null;
|
||||
tokens: AppTokens | null;
|
||||
}
|
||||
|
||||
const appLogin = (email: string, from: string, extra: object = {}) =>
|
||||
request(server)
|
||||
.post('/api/auth/app/login')
|
||||
.set('X-Forwarded-For', from)
|
||||
.set('User-Agent', APP_UA)
|
||||
.send({ email, password: CORRECT_PASSWORD, ...extra });
|
||||
const appRefresh = (refreshToken: string) =>
|
||||
request(server)
|
||||
.post('/api/auth/app/refresh')
|
||||
.set('X-Forwarded-For', '198.18.20.250')
|
||||
.set('User-Agent', APP_UA)
|
||||
.send({ refreshToken });
|
||||
const webLogin = (email: string, from: string) =>
|
||||
request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', from)
|
||||
.send({ email, password: CORRECT_PASSWORD });
|
||||
/** 응답의 tokens — 없으면 시험이 틀렸다 */
|
||||
const tokensOf = (res: Response): AppTokens => {
|
||||
const tokens = dataOf<AppSession>(res).tokens;
|
||||
if (!tokens) throw new Error('tokens 가 있어야 한다');
|
||||
return tokens;
|
||||
};
|
||||
const bearer = (token: string) => `Bearer ${token}`;
|
||||
const sidOf = (token: string) => jwt.decode<{ sid: string }>(token).sid;
|
||||
const meWith = (token: string) =>
|
||||
request(server).get('/api/auth/me').set('Authorization', bearer(token));
|
||||
|
||||
it('앱 로그인 → 웹 login 과 같은 칸 + tokens(no-store · 쿠키 없음, 두 토큰에 cli: app) → Bearer 로 me · 보호 API', async () => {
|
||||
const ME = 'app.login@e2e.arasystem.test';
|
||||
const res = await appLogin(ME, '198.18.20.1').expect(200);
|
||||
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
expect(dataOf<AppSession>(res)).toEqual({
|
||||
status: 'signedIn',
|
||||
email: ME,
|
||||
name: EMPLOYEE.name,
|
||||
...UNRESTRICTED,
|
||||
tokens: {
|
||||
accessToken: expect.any(String) as unknown,
|
||||
accessExpiresAt: expect.any(String) as unknown,
|
||||
refreshToken: expect.any(String) as unknown,
|
||||
refreshExpiresAt: expect.any(String) as unknown,
|
||||
},
|
||||
});
|
||||
const tokens = tokensOf(res);
|
||||
const access = jwt.decode<{ typ: string; cli?: string; exp: number }>(
|
||||
tokens.accessToken,
|
||||
);
|
||||
const refresh = jwt.decode<{ typ: string; cli?: string; exp: number }>(
|
||||
tokens.refreshToken,
|
||||
);
|
||||
expect(access).toMatchObject({ typ: 'access', cli: 'app' });
|
||||
expect(refresh).toMatchObject({ typ: 'refresh', cli: 'app' });
|
||||
expect(new Date(tokens.accessExpiresAt).getTime()).toBe(
|
||||
access.exp * 1000,
|
||||
);
|
||||
expect(new Date(tokens.refreshExpiresAt).getTime()).toBe(
|
||||
refresh.exp * 1000,
|
||||
);
|
||||
|
||||
const me = await meWith(tokens.accessToken).expect(200);
|
||||
expect(body(me).data).toEqual({
|
||||
...EMPLOYEE,
|
||||
email: ME,
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
await request(server)
|
||||
.get('/api/mailboxes')
|
||||
.set('Authorization', bearer(tokens.accessToken))
|
||||
.expect(200);
|
||||
// Bearer 없이는 로그인 전과 같다
|
||||
await request(server).get('/api/mailboxes').expect(401);
|
||||
});
|
||||
|
||||
it('웹 토큰을 Bearer 로 · 앱 토큰을 쿠키로 보내면 401 AUTH_001 — 리프레시 토큰도 서로의 갱신에서 401 (세션은 끊지 않는다)', async () => {
|
||||
const ME = 'app.cross@e2e.arasystem.test';
|
||||
const web = await webLogin(ME, '198.18.20.2').expect(200);
|
||||
const webAccess = cookieValue(setCookie(web, 'access_token'));
|
||||
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
|
||||
const appTokens = tokensOf(await appLogin(ME, '198.18.20.3').expect(200));
|
||||
|
||||
const webAsBearer = await meWith(webAccess).expect(401);
|
||||
expect(body(webAsBearer).error?.code).toBe('AUTH_001');
|
||||
const appAsCookie = await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `access_token=${appTokens.accessToken}`)
|
||||
.expect(401);
|
||||
expect(body(appAsCookie).error?.code).toBe('AUTH_001');
|
||||
// Bearer 가 있으면 같은 요청의 쿠키는 보지 않는다
|
||||
await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `access_token=${webAccess}`)
|
||||
.set('Authorization', bearer(webAccess))
|
||||
.expect(401);
|
||||
|
||||
const appAtWeb = await refreshWith(appTokens.refreshToken).expect(401);
|
||||
expect(body(appAtWeb).error?.code).toBe('AUTH_001');
|
||||
const webAtApp = await appRefresh(webRefresh).expect(401);
|
||||
expect(body(webAtApp).error?.code).toBe('AUTH_001');
|
||||
// 제 길로는 그대로 된다 — 위의 거절이 세션을 끊지 않았다
|
||||
await appRefresh(appTokens.refreshToken).expect(200);
|
||||
await refreshWith(webRefresh).expect(200);
|
||||
});
|
||||
|
||||
it('앱 갱신 — 1회용 회전(no-store) · 이미 쓴 토큰(두 번 지난 것)은 세션 전체를 끊는다 · 형식 오류는 400 · 빈 글 · 긴 글 · 틀린 토큰은 401', async () => {
|
||||
const ME = 'app.refresh@e2e.arasystem.test';
|
||||
const first = tokensOf(await appLogin(ME, '198.18.20.4').expect(200));
|
||||
|
||||
const rotated = await appRefresh(first.refreshToken).expect(200);
|
||||
expect(rotated.headers['cache-control']).toBe('private, no-store');
|
||||
expect(rotated.headers['set-cookie']).toBeUndefined();
|
||||
expect(dataOf<AppSession>(rotated)).toMatchObject({
|
||||
email: ME,
|
||||
name: EMPLOYEE.name,
|
||||
...UNRESTRICTED,
|
||||
});
|
||||
const second = tokensOf(rotated);
|
||||
expect(second.refreshToken).not.toBe(first.refreshToken);
|
||||
await meWith(second.accessToken).expect(200);
|
||||
|
||||
const third = tokensOf(await appRefresh(second.refreshToken).expect(200));
|
||||
const reused = await appRefresh(first.refreshToken).expect(401);
|
||||
expect(body(reused).error?.code).toBe('AUTH_001');
|
||||
// 세션이 끊겼다 — 최신 리프레시 토큰도, 그 액세스 토큰도 401
|
||||
await appRefresh(third.refreshToken).expect(401);
|
||||
await meWith(third.accessToken).expect(401);
|
||||
|
||||
for (const token of [
|
||||
'garbage',
|
||||
'',
|
||||
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
|
||||
]) {
|
||||
const rejected = await appRefresh(token).expect(401);
|
||||
expect(body(rejected).error?.code).toBe('AUTH_001');
|
||||
// 오류 응답도 no-store (계약 1절 "앱 로그인")
|
||||
expect(rejected.headers['cache-control']).toBe('private, no-store');
|
||||
}
|
||||
for (const payload of [{}, { refreshToken: 12345 }]) {
|
||||
const malformed = await request(server)
|
||||
.post('/api/auth/app/refresh')
|
||||
.send(payload)
|
||||
.expect(400);
|
||||
expect(body(malformed).error?.code).toBe('VAL_001');
|
||||
expect(malformed.headers['cache-control']).toBe('private, no-store');
|
||||
}
|
||||
});
|
||||
|
||||
it('앱 세션의 30초 유예는 tokens: null 로 답하고 그 세션을 끝낸다(새 토큰 · 액세스 토큰도 401) — 웹 세션의 유예는 세션을 남긴다', async () => {
|
||||
const ME = 'app.grace@e2e.arasystem.test';
|
||||
const first = tokensOf(await appLogin(ME, '198.18.20.19').expect(200));
|
||||
const second = tokensOf(await appRefresh(first.refreshToken).expect(200));
|
||||
|
||||
const grace = await appRefresh(first.refreshToken).expect(200);
|
||||
expect(grace.headers['cache-control']).toBe('private, no-store');
|
||||
expect(dataOf<AppSession>(grace)).toEqual({
|
||||
email: ME,
|
||||
name: EMPLOYEE.name,
|
||||
...UNRESTRICTED,
|
||||
tokens: null,
|
||||
});
|
||||
await appRefresh(second.refreshToken).expect(401);
|
||||
await meWith(second.accessToken).expect(401);
|
||||
const rows: { revoked_reason: string | null }[] = await dataSource.query(
|
||||
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
|
||||
[sidOf(second.refreshToken)],
|
||||
);
|
||||
expect(rows).toEqual([{ revoked_reason: 'app-refresh-grace' }]);
|
||||
|
||||
// 웹 — 유예 뒤에도 이긴 쪽의 쿠키로 이어 쓴다
|
||||
const web = await webLogin(ME, '198.18.20.20').expect(200);
|
||||
const webFirst = cookieValue(setCookie(web, 'refresh_token'));
|
||||
const webSecond = rotated(await refreshWith(webFirst).expect(200));
|
||||
const webGrace = await refreshWith(webFirst).expect(200);
|
||||
expect(webGrace.headers['set-cookie']).toBeUndefined();
|
||||
await refreshWith(webSecond).expect(200);
|
||||
});
|
||||
|
||||
it('앱 로그아웃 — 리프레시 토큰으로 · 틀리거나 없으면 Bearer 의 sid 로 · 둘 다 틀려도 200, 끝낸 세션의 액세스 토큰은 바로 401', async () => {
|
||||
const ME = 'app.logout@e2e.arasystem.test';
|
||||
const logout = () => request(server).post('/api/auth/app/logout');
|
||||
|
||||
const byRefresh = tokensOf(await appLogin(ME, '198.18.20.5').expect(200));
|
||||
const first = await logout()
|
||||
.send({ refreshToken: byRefresh.refreshToken })
|
||||
.expect(200);
|
||||
expect(body(first)).toEqual({ success: true, data: null });
|
||||
await appRefresh(byRefresh.refreshToken).expect(401);
|
||||
await meWith(byRefresh.accessToken).expect(401);
|
||||
|
||||
const wrongRefresh = tokensOf(
|
||||
await appLogin(ME, '198.18.20.6').expect(200),
|
||||
);
|
||||
await logout()
|
||||
.set('Authorization', bearer(wrongRefresh.accessToken))
|
||||
.send({ refreshToken: 'garbage' })
|
||||
.expect(200);
|
||||
await appRefresh(wrongRefresh.refreshToken).expect(401);
|
||||
|
||||
const emptyBody = tokensOf(await appLogin(ME, '198.18.20.7').expect(200));
|
||||
await logout()
|
||||
.set('Authorization', bearer(emptyBody.accessToken))
|
||||
.send({})
|
||||
.expect(200);
|
||||
await appRefresh(emptyBody.refreshToken).expect(401);
|
||||
|
||||
// 빈 글 · 긴 글도 틀린 토큰처럼 보고 Bearer 의 sid 로 끝낸다 (200)
|
||||
for (const refreshToken of [
|
||||
'',
|
||||
'x'.repeat(MAX_REFRESH_TOKEN_LENGTH + 1),
|
||||
]) {
|
||||
const odd = tokensOf(await appLogin(ME, '198.18.20.21').expect(200));
|
||||
await logout()
|
||||
.set('Authorization', bearer(odd.accessToken))
|
||||
.send({ refreshToken })
|
||||
.expect(200);
|
||||
await appRefresh(odd.refreshToken).expect(401);
|
||||
}
|
||||
// 본문 모양이 틀릴 때만 400
|
||||
const malformed = await logout()
|
||||
.send({ refreshToken: 12345 })
|
||||
.expect(400);
|
||||
expect(body(malformed).error?.code).toBe('VAL_001');
|
||||
|
||||
const untouched = tokensOf(await appLogin(ME, '198.18.20.8').expect(200));
|
||||
const neither = await logout()
|
||||
.set('Authorization', 'Bearer garbage')
|
||||
.send({ refreshToken: 'garbage' })
|
||||
.expect(200);
|
||||
expect(body(neither)).toEqual({ success: true, data: null });
|
||||
await logout().expect(200);
|
||||
// 아무 세션도 끝내지 않았다
|
||||
await appRefresh(untouched.refreshToken).expect(200);
|
||||
});
|
||||
|
||||
it('앱 로그아웃에 웹 쿠키(access_token · refresh_token)가 함께 와도 웹 세션은 그대로다 — 앱 경로는 쿠키를 보지 않는다', async () => {
|
||||
const ME = 'app.logout.cookies@e2e.arasystem.test';
|
||||
const web = await webLogin(ME, '198.18.20.22').expect(200);
|
||||
const webAccess = cookieValue(setCookie(web, 'access_token'));
|
||||
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
|
||||
const appTokens = tokensOf(
|
||||
await appLogin(ME, '198.18.20.23').expect(200),
|
||||
);
|
||||
|
||||
await request(server)
|
||||
.post('/api/auth/app/logout')
|
||||
.set('Cookie', [
|
||||
`access_token=${webAccess}`,
|
||||
`refresh_token=${webRefresh}`,
|
||||
])
|
||||
.set('Authorization', bearer(appTokens.accessToken))
|
||||
.send({})
|
||||
.expect(200);
|
||||
await appRefresh(appTokens.refreshToken).expect(401);
|
||||
// 웹 세션은 끊지 않았다 — 쿠키를 지우지도 않는다
|
||||
await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `access_token=${webAccess}`)
|
||||
.expect(200);
|
||||
await refreshWith(webRefresh).expect(200);
|
||||
|
||||
// 쿠키만 오고 본문 · Bearer 가 없으면 아무 세션도 끝내지 않는다
|
||||
const webOnly = await webLogin(ME, '198.18.20.24').expect(200);
|
||||
const onlyRefresh = cookieValue(setCookie(webOnly, 'refresh_token'));
|
||||
const res = await request(server)
|
||||
.post('/api/auth/app/logout')
|
||||
.set('Cookie', `refresh_token=${onlyRefresh}`)
|
||||
.send({})
|
||||
.expect(200);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
await refreshWith(onlyRefresh).expect(200);
|
||||
});
|
||||
|
||||
it('앱 표시가 있는데 세션 id 가 없거나 UUID 가 아닌 액세스 토큰은 401 AUTH_001', async () => {
|
||||
const ME = 'app.nosid@e2e.arasystem.test';
|
||||
for (const extra of [{}, { sid: 'not-a-uuid' }]) {
|
||||
const token = sign(
|
||||
{ sub: ME, typ: 'access', name: EMPLOYEE.name, cli: 'app', ...extra },
|
||||
600,
|
||||
);
|
||||
const res = await meWith(token).expect(401);
|
||||
expect(body(res).error?.code).toBe('AUTH_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('2단계 인증을 켠 직원 — 비밀번호만이면 twoFactorRequired · tokens: null(세션 없음), 코드를 더하면 tokens', async () => {
|
||||
const ME = 'app.otp@e2e.arasystem.test';
|
||||
const secret = randomBytes(20);
|
||||
twoFactorUrls.set(
|
||||
ME,
|
||||
buildOtpauthUrl({
|
||||
issuer: 'ARA SYSTEM',
|
||||
account: ME,
|
||||
secret: encodeBase32(secret),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const first = await appLogin(ME, '198.18.20.17').expect(200);
|
||||
expect(dataOf<AppSession>(first)).toEqual({
|
||||
status: 'twoFactorRequired',
|
||||
email: null,
|
||||
name: null,
|
||||
...UNRESTRICTED,
|
||||
tokens: null,
|
||||
});
|
||||
|
||||
const second = await appLogin(ME, '198.18.20.18', {
|
||||
otpCode: otpCodeOf(secret),
|
||||
}).expect(200);
|
||||
expect(dataOf<AppSession>(second)).toMatchObject({
|
||||
status: 'signedIn',
|
||||
email: ME,
|
||||
});
|
||||
await meWith(tokensOf(second).accessToken).expect(200);
|
||||
} finally {
|
||||
twoFactorUrls.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('제한된 세션도 Bearer 로 같다 — 허용한 경로만, 그 밖은 403 AUTH_010 → 앱에서 비밀번호를 바꾸면 이 앱 세션의 제한이 풀린다', async () => {
|
||||
const ME = 'app.expired@e2e.arasystem.test';
|
||||
expiredPasswords.add(ME);
|
||||
try {
|
||||
const res = await appLogin(ME, '198.18.20.9').expect(200);
|
||||
expect(dataOf<AppSession>(res)).toMatchObject({
|
||||
status: 'signedIn',
|
||||
restriction: 'passwordChange',
|
||||
});
|
||||
const tokens = tokensOf(res);
|
||||
const as = (test: HttpTest) =>
|
||||
test.set('Authorization', bearer(tokens.accessToken));
|
||||
|
||||
const me = await as(request(server).get('/api/auth/me')).expect(200);
|
||||
expect(body(me).data).toMatchObject({ restriction: 'passwordChange' });
|
||||
await as(request(server).get('/api/auth/sessions')).expect(200);
|
||||
await as(request(server).get('/api/two-factor')).expect(200);
|
||||
const blocked = await as(request(server).get('/api/mailboxes')).expect(
|
||||
403,
|
||||
);
|
||||
expect(body(blocked).error?.code).toBe('AUTH_010');
|
||||
|
||||
await as(request(server).put('/api/auth/password'))
|
||||
.set('X-Forwarded-For', '198.18.20.9')
|
||||
.send({
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: 'new-password-for-e2e-app',
|
||||
})
|
||||
.expect(200);
|
||||
// 옛 액세스 토큰(rst)으로도 세션 표의 지금 값 — 제한이 풀렸다
|
||||
await as(request(server).get('/api/mailboxes')).expect(200);
|
||||
const renewed = await appRefresh(tokens.refreshToken).expect(200);
|
||||
expect(dataOf<AppSession>(renewed)).toMatchObject(UNRESTRICTED);
|
||||
} finally {
|
||||
expiredPasswords.delete(ME);
|
||||
}
|
||||
});
|
||||
|
||||
it('앱에서 비밀번호를 바꾸면 이 앱 세션(sid)만 남고 웹 · 다른 앱 세션은 끊긴다', async () => {
|
||||
const ME = 'app.password@e2e.arasystem.test';
|
||||
const web = await webLogin(ME, '198.18.20.10').expect(200);
|
||||
const webRefresh = cookieValue(setCookie(web, 'refresh_token'));
|
||||
const other = tokensOf(await appLogin(ME, '198.18.20.11').expect(200));
|
||||
const mine = tokensOf(await appLogin(ME, '198.18.20.12').expect(200));
|
||||
|
||||
await request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', '198.18.20.12')
|
||||
.set('Authorization', bearer(mine.accessToken))
|
||||
.send({
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: 'new-password-for-e2e-app-2',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await meWith(mine.accessToken).expect(200);
|
||||
await appRefresh(mine.refreshToken).expect(200);
|
||||
await meWith(other.accessToken).expect(401);
|
||||
await appRefresh(other.refreshToken).expect(401);
|
||||
await refreshWith(webRefresh).expect(401);
|
||||
const rows: { revoked_reason: string | null }[] = await dataSource.query(
|
||||
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
|
||||
[sidOf(other.refreshToken)],
|
||||
);
|
||||
expect(rows).toEqual([{ revoked_reason: 'password-changed' }]);
|
||||
});
|
||||
|
||||
it('로그인된 기기 — 앱이 부르면 그 앱 세션(sid)이 지금 세션(맨 앞)이고 ARA SYSTEM 앱 · Android, 다른 곳 모두 끊기는 그 앱 세션을 남긴다', async () => {
|
||||
const ME = 'app.sessions@e2e.arasystem.test';
|
||||
const web = await webLogin(ME, '198.18.20.13').expect(200);
|
||||
const webAccess = cookieValue(setCookie(web, 'access_token'));
|
||||
const appTokens = tokensOf(
|
||||
await appLogin(ME, '198.18.20.14').expect(200),
|
||||
);
|
||||
const appSid = sidOf(appTokens.accessToken);
|
||||
|
||||
const listed = await request(server)
|
||||
.get('/api/auth/sessions')
|
||||
.set('Authorization', bearer(appTokens.accessToken))
|
||||
.expect(200);
|
||||
const page = dataOf<
|
||||
Page<{
|
||||
id: string;
|
||||
isCurrent: boolean;
|
||||
ip: string | null;
|
||||
device: { browser: string | null; os: string | null } | null;
|
||||
}>
|
||||
>(listed);
|
||||
expect(page.items[0]).toMatchObject({
|
||||
id: appSid,
|
||||
isCurrent: true,
|
||||
ip: '198.18.20.14',
|
||||
client: 'app',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
|
||||
});
|
||||
expect(page.items[1]).toMatchObject({
|
||||
id: sidOf(webAccess),
|
||||
isCurrent: false,
|
||||
client: 'web',
|
||||
});
|
||||
expect(page.items.filter(({ isCurrent }) => isCurrent)).toHaveLength(1);
|
||||
expect(page.pageInfo.total).toBe(2);
|
||||
const rows: { client: string }[] = await dataSource.query(
|
||||
'SELECT client FROM auth_sessions WHERE id = ANY($1) ORDER BY client',
|
||||
[[appSid, sidOf(webAccess)]],
|
||||
);
|
||||
expect(rows).toEqual([{ client: 'app' }, { client: 'web' }]);
|
||||
|
||||
// 지금 세션은 하나 끊기로 끊지 못한다 (로그아웃을 쓴다)
|
||||
const self = await request(server)
|
||||
.delete(`/api/auth/sessions/${appSid}`)
|
||||
.set('Authorization', bearer(appTokens.accessToken))
|
||||
.expect(400);
|
||||
expect(body(self).error?.code).toBe('VAL_001');
|
||||
|
||||
const ended = await request(server)
|
||||
.delete('/api/auth/sessions')
|
||||
.set('Authorization', bearer(appTokens.accessToken))
|
||||
.expect(200);
|
||||
expect(body(ended).data).toEqual({ ended: 1 });
|
||||
await request(server)
|
||||
.get('/api/auth/me')
|
||||
.set('Cookie', `access_token=${webAccess}`)
|
||||
.expect(401);
|
||||
await meWith(appTokens.accessToken).expect(200);
|
||||
});
|
||||
|
||||
it('실패 카운터는 웹 · 앱이 함께 센다 — 같은 주소 15분 10회를 웹 5 · 앱 5 로 채우면 맞는 비밀번호도 앱 · 웹 모두 429 REQ_001', async () => {
|
||||
const ME = 'app.limit@e2e.arasystem.test';
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const web = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', `198.18.21.${i}`)
|
||||
.send({ email: ME, password: 'wrong-password' })
|
||||
.expect(401);
|
||||
expect(body(web).error?.code).toBe('AUTH_004');
|
||||
const appFailure = await request(server)
|
||||
.post('/api/auth/app/login')
|
||||
.set('X-Forwarded-For', `198.18.22.${i}`)
|
||||
.send({ email: ME, password: 'wrong-password' })
|
||||
.expect(401);
|
||||
expect(body(appFailure).error?.code).toBe('AUTH_004');
|
||||
}
|
||||
|
||||
const appLocked = await appLogin(ME, '198.18.23.1').expect(429);
|
||||
expect(body(appLocked).error?.code).toBe('REQ_001');
|
||||
const webLocked = await webLogin(ME, '198.18.23.2').expect(429);
|
||||
expect(body(webLocked).error?.code).toBe('REQ_001');
|
||||
});
|
||||
|
||||
it('로그인 기록 — 앱 로그인은 방식 app · 기기 ARA SYSTEM 앱 · Android (성공 · 비밀번호 틀림)', async () => {
|
||||
const ME = 'app.history@e2e.arasystem.test';
|
||||
// 비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 사내 목록의 답을 직접 정한다(테스트 차례와 상관없게)
|
||||
const directory = jest
|
||||
.spyOn(app.get(DirectoryService, { strict: false }), 'hasEmail')
|
||||
.mockResolvedValue(true);
|
||||
try {
|
||||
await request(server)
|
||||
.post('/api/auth/app/login')
|
||||
.set('X-Forwarded-For', '198.18.20.15')
|
||||
.set('User-Agent', APP_UA)
|
||||
.send({ email: ME, password: 'wrong-password' })
|
||||
.expect(401);
|
||||
const appTokens = tokensOf(
|
||||
await appLogin(ME, '198.18.20.16').expect(200),
|
||||
);
|
||||
|
||||
const res = await request(server)
|
||||
.get('/api/login-events')
|
||||
.set('Authorization', bearer(appTokens.accessToken))
|
||||
.expect(200);
|
||||
expect(dataOf<Page<Record<string, unknown>>>(res).items).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
isSuccess: true,
|
||||
isTwoFactor: false,
|
||||
failureReason: null,
|
||||
ip: '198.18.20.16',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'app',
|
||||
isSuccess: false,
|
||||
failureReason: 'wrongPassword',
|
||||
ip: '198.18.20.15',
|
||||
device: { browser: 'ARA SYSTEM 앱', os: 'Android' },
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
directory.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('웹 로그인이 앱 꼴 User-Agent 를 보내도 앱 이름을 받지 않는다 — 로그인된 기기 client web · 브라우저 null, 로그인 기록 webmail', async () => {
|
||||
const ME = 'app.spoof@e2e.arasystem.test';
|
||||
const web = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.18.20.25')
|
||||
.set('User-Agent', APP_UA)
|
||||
.send({ email: ME, password: CORRECT_PASSWORD })
|
||||
.expect(200);
|
||||
const cookies = [
|
||||
`access_token=${cookieValue(setCookie(web, 'access_token'))}`,
|
||||
`refresh_token=${cookieValue(setCookie(web, 'refresh_token'))}`,
|
||||
];
|
||||
|
||||
const sessionsRes = await request(server)
|
||||
.get('/api/auth/sessions')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(dataOf<Page<Record<string, unknown>>>(sessionsRes).items).toEqual([
|
||||
expect.objectContaining({
|
||||
isCurrent: true,
|
||||
client: 'web',
|
||||
device: { browser: null, os: 'Android' },
|
||||
}),
|
||||
]);
|
||||
const events = await request(server)
|
||||
.get('/api/login-events')
|
||||
.set('Cookie', cookies)
|
||||
.expect(200);
|
||||
expect(dataOf<Page<Record<string, unknown>>>(events).items).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'webmail',
|
||||
device: { browser: null, os: 'Android' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('출처 정보가 없는 앱 요청은 출처 검사를 지난다 — 다른 사이트의 Origin 이면 앱 경로도 403 AUTH_003', async () => {
|
||||
await request(server).post('/api/auth/app/logout').send({}).expect(200);
|
||||
const evil = await request(server)
|
||||
.post('/api/auth/app/login')
|
||||
.set('Origin', 'https://evil.example')
|
||||
.send({ email: 'app.origin@e2e.arasystem.test', password: 'x' })
|
||||
.expect(403);
|
||||
expect(body(evil).error?.code).toBe('AUTH_003');
|
||||
});
|
||||
});
|
||||
|
||||
describe('출처 검사 (상태 변경 요청)', () => {
|
||||
const EVIL_ORIGIN = 'https://evil.example';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user