From 6ee6c71ace7ea503dceda71623ef98d2331cfc97 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 6 Nov 2026 16:38:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=EC=95=B1=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=C2=B7=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=C2=B7=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=A7=8C=EB=93=A0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계약 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 --- .../api-standard-response.decorator.ts | 2 +- .../decorators/current-user.decorator.ts | 11 + backend/src/common/guards/jwt-auth.guard.ts | 7 +- backend/src/main.ts | 21 +- .../1793933074677-AddAppSessionClient.ts | 24 + .../account-security/dto/login-event.dto.ts | 12 +- .../account-security/login-event.entity.ts | 13 +- .../login-events.controller.ts | 17 +- .../login-events.service.spec.ts | 34 +- .../account-security/login-events.service.ts | 19 +- .../account-security/mail-engine-event.ts | 4 +- backend/src/modules/auth/access-token.spec.ts | 70 +++ backend/src/modules/auth/access-token.ts | 62 ++ .../modules/auth/app-auth.controller.spec.ts | 225 +++++++ .../src/modules/auth/app-auth.controller.ts | 185 ++++++ backend/src/modules/auth/auth-cookies.spec.ts | 8 +- backend/src/modules/auth/auth-cookies.ts | 4 + .../src/modules/auth/auth-session.entity.ts | 24 +- .../modules/auth/auth-session.policy.spec.ts | 21 +- .../src/modules/auth/auth-session.policy.ts | 16 +- .../modules/auth/auth-sessions.controller.ts | 16 +- .../auth/auth-sessions.service.spec.ts | 103 +++- .../src/modules/auth/auth-sessions.service.ts | 56 +- backend/src/modules/auth/auth-token.types.ts | 9 + backend/src/modules/auth/auth.constants.ts | 31 + backend/src/modules/auth/auth.controller.ts | 20 +- backend/src/modules/auth/auth.module.ts | 4 +- backend/src/modules/auth/auth.service.spec.ts | 397 +++++++++++- backend/src/modules/auth/auth.service.ts | 273 +++++++-- .../src/modules/auth/dto/app-auth.dto.spec.ts | 62 ++ backend/src/modules/auth/dto/app-auth.dto.ts | 147 +++++ .../src/modules/auth/dto/auth-session.dto.ts | 12 +- backend/src/modules/auth/jwt.strategy.spec.ts | 137 ++++- backend/src/modules/auth/jwt.strategy.ts | 56 +- backend/src/modules/ops/account-commands.ts | 18 +- backend/src/modules/ops/admin-commands.ts | 6 +- backend/src/modules/ops/lock-commands.ts | 20 +- backend/src/modules/ops/migration-commands.ts | 40 +- backend/src/modules/ops/ops-commands.spec.ts | 30 +- backend/src/modules/ops/ops-data.service.ts | 27 +- .../system-mail/system-notices.spec.ts | 4 +- .../src/modules/system-mail/system-notices.ts | 4 +- backend/src/modules/user/user.controller.ts | 2 +- .../src/shared/utils/openapi-security.spec.ts | 52 ++ backend/src/shared/utils/openapi-security.ts | 42 ++ backend/src/shared/utils/user-agent.spec.ts | 44 ++ backend/src/shared/utils/user-agent.ts | 55 +- backend/test/app.e2e-spec.ts | 579 ++++++++++++++++++ 48 files changed, 2783 insertions(+), 242 deletions(-) create mode 100644 backend/src/migrations/1793933074677-AddAppSessionClient.ts create mode 100644 backend/src/modules/auth/access-token.spec.ts create mode 100644 backend/src/modules/auth/access-token.ts create mode 100644 backend/src/modules/auth/app-auth.controller.spec.ts create mode 100644 backend/src/modules/auth/app-auth.controller.ts create mode 100644 backend/src/modules/auth/dto/app-auth.dto.spec.ts create mode 100644 backend/src/modules/auth/dto/app-auth.dto.ts create mode 100644 backend/src/shared/utils/openapi-security.spec.ts create mode 100644 backend/src/shared/utils/openapi-security.ts diff --git a/backend/src/common/decorators/api-standard-response.decorator.ts b/backend/src/common/decorators/api-standard-response.decorator.ts index b8f7a4c..3d3c539 100644 --- a/backend/src/common/decorators/api-standard-response.decorator.ts +++ b/backend/src/common/decorators/api-standard-response.decorator.ts @@ -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단계 켜기 전). diff --git a/backend/src/common/decorators/current-user.decorator.ts b/backend/src/common/decorators/current-user.decorator.ts index bc7b60b..bc7c160 100644 --- a/backend/src/common/decorators/current-user.decorator.ts +++ b/backend/src/common/decorators/current-user.decorator.ts @@ -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 으로 막는다 diff --git a/backend/src/common/guards/jwt-auth.guard.ts b/backend/src/common/guards/jwt-auth.guard.ts index ccced48..f27b4fa 100644 --- a/backend/src/common/guards/jwt-auth.guard.ts +++ b/backend/src/common/guards/jwt-auth.guard.ts @@ -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() */ diff --git a/backend/src/main.ts b/backend/src/main.ts index 4d5c9d9..1425ec6 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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( @@ -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 수신 시 새 요청을 받지 않고 diff --git a/backend/src/migrations/1793933074677-AddAppSessionClient.ts b/backend/src/migrations/1793933074677-AddAppSessionClient.ts new file mode 100644 index 0000000..1728867 --- /dev/null +++ b/backend/src/migrations/1793933074677-AddAppSessionClient.ts @@ -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 { + await queryRunner.query( + `ALTER TABLE "auth_sessions" ADD "client" character varying(16) NOT NULL DEFAULT 'web'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "auth_sessions" DROP COLUMN "client"`); + } +} diff --git a/backend/src/modules/account-security/dto/login-event.dto.ts b/backend/src/modules/account-security/dto/login-event.dto.ts index e706541..59d3b86 100644 --- a/backend/src/modules/account-security/dto/login-event.dto.ts +++ b/backend/src/modules/account-security/dto/login-event.dto.ts @@ -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, }) diff --git a/backend/src/modules/account-security/login-event.entity.ts b/backend/src/modules/account-security/login-event.entity.ts index e3d710f..e19c7c6 100644 --- a/backend/src/modules/account-security/login-event.entity.ts +++ b/backend/src/modules/account-security/login-event.entity.ts @@ -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; /** * 실패 까닭 (계약 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', diff --git a/backend/src/modules/account-security/login-events.controller.ts b/backend/src/modules/account-security/login-events.controller.ts index 0c8cb99..7af84bb 100644 --- a/backend/src/modules/account-security/login-events.controller.ts +++ b/backend/src/modules/account-security/login-events.controller.ts @@ -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).`, }) diff --git a/backend/src/modules/account-security/login-events.service.spec.ts b/backend/src/modules/account-security/login-events.service.spec.ts index bc9e0a0..d69a350 100644 --- a/backend/src/modules/account-security/login-events.service.spec.ts +++ b/backend/src/modules/account-security/login-events.service.spec.ts @@ -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[0]>, + overrides: Partial[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')); }); diff --git a/backend/src/modules/account-security/login-events.service.ts b/backend/src/modules/account-security/login-events.service.ts index f9c7cf4..e1cecc9 100644 --- a/backend/src/modules/account-security/login-events.service.ts +++ b/backend/src/modules/account-security/login-events.service.ts @@ -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 { 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)}`, ); } } diff --git a/backend/src/modules/account-security/mail-engine-event.ts b/backend/src/modules/account-security/mail-engine-event.ts index b738a6d..54c7c83 100644 --- a/backend/src/modules/account-security/mail-engine-event.ts +++ b/backend/src/modules/account-security/mail-engine-event.ts @@ -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; + /** 메일 프로그램의 방식 — imap · smtp (웹메일 · 앱은 서버가 판정해 남긴다) */ + readonly method: Exclude; readonly outcome: EngineLoginOutcome; /** 엔진이 적은 계정 이름 — 주소, 앱 비밀번호면 로컬 이름 (소문자) */ readonly accountName: string; diff --git a/backend/src/modules/auth/access-token.spec.ts b/backend/src/modules/auth/access-token.spec.ts new file mode 100644 index 0000000..412e7d6 --- /dev/null +++ b/backend/src/modules/auth/access-token.spec.ts @@ -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, + }); + }); +}); diff --git a/backend/src/modules/auth/access-token.ts b/backend/src/modules/auth/access-token.ts new file mode 100644 index 0000000..679960c --- /dev/null +++ b/backend/src/modules/auth/access-token.ts @@ -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, + }; +} diff --git a/backend/src/modules/auth/app-auth.controller.spec.ts b/backend/src/modules/auth/app-auth.controller.spec.ts new file mode 100644 index 0000000..5940932 --- /dev/null +++ b/backend/src/modules/auth/app-auth.controller.spec.ts @@ -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 = {}) { + 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); + } + }); +}); diff --git a/backend/src/modules/auth/app-auth.controller.ts b/backend/src/modules/auth/app-auth.controller.ts new file mode 100644 index 0000000..6f99711 --- /dev/null +++ b/backend/src/modules/auth/app-auth.controller.ts @@ -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 { + 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 { + 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 { + const bearer = parseBearerAuthorization(req.headers.authorization); + await this.auth.logoutApp(dto.refreshToken, bearer || undefined); + return null; + } +} diff --git a/backend/src/modules/auth/auth-cookies.spec.ts b/backend/src/modules/auth/auth-cookies.spec.ts index 47b1937..1f8da0c 100644 --- a/backend/src/modules/auth/auth-cookies.spec.ts +++ b/backend/src/modules/auth/auth-cookies.spec.ts @@ -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); diff --git a/backend/src/modules/auth/auth-cookies.ts b/backend/src/modules/auth/auth-cookies.ts index 51322c1..d41eea0 100644 --- a/backend/src/modules/auth/auth-cookies.ts +++ b/backend/src/modules/auth/auth-cookies.ts @@ -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'; diff --git a/backend/src/modules/auth/auth-session.entity.ts b/backend/src/modules/auth/auth-session.entity.ts index f23870d..81d5804 100644 --- a/backend/src/modules/auth/auth-session.entity.ts +++ b/backend/src/modules/auth/auth-session.entity.ts @@ -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; } diff --git a/backend/src/modules/auth/auth-session.policy.spec.ts b/backend/src/modules/auth/auth-session.policy.spec.ts index 24294a6..f6e71a7 100644 --- a/backend/src/modules/auth/auth-session.policy.spec.ts +++ b/backend/src/modules/auth/auth-session.policy.spec.ts @@ -17,6 +17,7 @@ const OLDER = hashRefreshToken('older.token'); function session(overrides: Partial = {}): 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 { }; } -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', () => { diff --git a/backend/src/modules/auth/auth-session.policy.ts b/backend/src/modules/auth/auth-session.policy.ts index ef7d0d2..06488cc 100644 --- a/backend/src/modules/auth/auth-session.policy.ts +++ b/backend/src/modules/auth/auth-session.policy.ts @@ -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'; diff --git a/backend/src/modules/auth/auth-sessions.controller.ts b/backend/src/modules/auth/auth-sessions.controller.ts index a07970d..16147a3 100644 --- a/backend/src/modules/auth/auth-sessions.controller.ts +++ b/backend/src/modules/auth/auth-sessions.controller.ts @@ -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 { 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 { 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 { const ended = await this.authSessions.endOthers( - user.email, + user, readCookie(req, REFRESH_TOKEN_COOKIE), ); return { ended }; diff --git a/backend/src/modules/auth/auth-sessions.service.spec.ts b/backend/src/modules/auth/auth-sessions.service.spec.ts index 06d6f6b..567ae8e 100644 --- a/backend/src/modules/auth/auth-sessions.service.spec.ts +++ b/backend/src/modules/auth/auth-sessions.service.spec.ts @@ -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'); + }); }); diff --git a/backend/src/modules/auth/auth-sessions.service.ts b/backend/src/modules/auth/auth-sessions.service.ts index b068a80..1be522a 100644 --- a/backend/src/modules/auth/auth-sessions.service.ts +++ b/backend/src/modules/auth/auth-sessions.service.ts @@ -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> { - 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 { - 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 { - 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', diff --git a/backend/src/modules/auth/auth-token.types.ts b/backend/src/modules/auth/auth-token.types.ts index 128657d..b1fc4d7 100644 --- a/backend/src/modules/auth/auth-token.types.ts +++ b/backend/src/modules/auth/auth-token.types.ts @@ -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 과 같은 값이다(로그인 · 갱신 때 정한다) diff --git a/backend/src/modules/auth/auth.constants.ts b/backend/src/modules/auth/auth.constants.ts index 3580a20..4e0b45a 100644 --- a/backend/src/modules/auth/auth.constants.ts +++ b/backend/src/modules/auth/auth.constants.ts @@ -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; diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 94bb5c2..c4d8b84 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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단계 인증은 그대로 남는다). ' + diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 9ad710a..d1bd871 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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, diff --git a/backend/src/modules/auth/auth.service.spec.ts b/backend/src/modules/auth/auth.service.spec.ts index 5e40a58..44ff825 100644 --- a/backend/src/modules/auth/auth.service.spec.ts +++ b/backend/src/modules/auth/auth.service.spec.ts @@ -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, + }); + }); + }); + }); }); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 914717a..ca8092f 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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, + failureReason: LoginFailureReason, ): Promise { - 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 { 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 { - 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 { 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 { + 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 { 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 { + // 빈 글 · 틀린 리프레시 토큰(길이 · 서명 · 종류 · 웹 토큰)은 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 { 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 { + 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 { 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 { + 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(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 { + let payload: AuthTokenPayload; + try { + payload = await this.jwt.verifyAsync(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 { 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 = + 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단계) */ diff --git a/backend/src/modules/auth/dto/app-auth.dto.spec.ts b/backend/src/modules/auth/dto/app-auth.dto.spec.ts new file mode 100644 index 0000000..d8c82f7 --- /dev/null +++ b/backend/src/modules/auth/dto/app-auth.dto.spec.ts @@ -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 { + 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'); + } + }); +}); diff --git a/backend/src/modules/auth/dto/app-auth.dto.ts b/backend/src/modules/auth/dto/app-auth.dto.ts new file mode 100644 index 0000000..d429449 --- /dev/null +++ b/backend/src/modules/auth/dto/app-auth.dto.ts @@ -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; +} diff --git a/backend/src/modules/auth/dto/auth-session.dto.ts b/backend/src/modules/auth/dto/auth-session.dto.ts index f071f9c..58c2d59 100644 --- a/backend/src/modules/auth/dto/auth-session.dto.ts +++ b/backend/src/modules/auth/dto/auth-session.dto.ts @@ -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; diff --git a/backend/src/modules/auth/jwt.strategy.spec.ts b/backend/src/modules/auth/jwt.strategy.spec.ts index 228241a..3414aec 100644 --- a/backend/src/modules/auth/jwt.strategy.spec.ts +++ b/backend/src/modules/auth/jwt.strategy.spec.ts @@ -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, ): Promise { @@ -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'); + }); }); diff --git a/backend/src/modules/auth/jwt.strategy.ts b/backend/src/modules/auth/jwt.strategy.ts index 0559f87..2a3ced0 100644 --- a/backend/src/modules/auth/jwt.strategy.ts +++ b/backend/src/modules/auth/jwt.strategy.ts @@ -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 { + async validate( + request: AccessTokenRequest, + payload: AuthTokenPayload, + ): Promise { 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) } diff --git a/backend/src/modules/ops/account-commands.ts b/backend/src/modules/ops/account-commands.ts index 1c3beb0..b0b818b 100644 --- a/backend/src/modules/ops/account-commands.ts +++ b/backend/src/modules/ops/account-commands.ts @@ -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 ? [ ' 회사 정책으로 필수인 직원 — 다음 로그인 때 새 휴대폰으로 켜는 단계가 나온다', diff --git a/backend/src/modules/ops/admin-commands.ts b/backend/src/modules/ops/admin-commands.ts index 55bf25a..9d3e06a 100644 --- a/backend/src/modules/ops/admin-commands.ts +++ b/backend/src/modules/ops/admin-commands.ts @@ -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 { 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), ); // 메일 이전 중 판정(실행 기록과 함께) — 읽지 못하면 이전 중 표시 없이 보이고 알린다 diff --git a/backend/src/modules/ops/lock-commands.ts b/backend/src/modules/ops/lock-commands.ts index 427ff07..38e7a52 100644 --- a/backend/src/modules/ops/lock-commands.ts +++ b/backend/src/modules/ops/lock-commands.ts @@ -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 ( diff --git a/backend/src/modules/ops/migration-commands.ts b/backend/src/modules/ops/migration-commands.ts index 2fa47ea..7b59a56 100644 --- a/backend/src/modules/ops/migration-commands.ts +++ b/backend/src/modules/ops/migration-commands.ts @@ -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({ diff --git a/backend/src/modules/ops/ops-commands.spec.ts b/backend/src/modules/ops/ops-commands.spec.ts index 30091cb..d0f4c02 100644 --- a/backend/src/modules/ops/ops-commands.spec.ts +++ b/backend/src/modules/ops/ops-commands.spec.ts @@ -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( '못 한 계정은 까닭을 고친 뒤 같은 실행에서 「다시 만들기」(빈 값)를 한 번 더 — 모두 이번 값이 됩니다.', diff --git a/backend/src/modules/ops/ops-data.service.ts b/backend/src/modules/ops/ops-data.service.ts index e52065e..5df7b84 100644 --- a/backend/src/modules/ops/ops-data.service.ts +++ b/backend/src/modules/ops/ops-data.service.ts @@ -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 { return this.sessions.revokeAllOf( @@ -157,16 +160,22 @@ export class OpsDataService { }); } - /** 마지막으로 웹메일에 로그인한 때 (성공한 웹메일 로그인 기록 — 90일 보존 안) */ - async lastWebmailLogins( - emails: readonly string[], - ): Promise> { + /** + * 마지막으로 웹메일 · 앱에 로그인한 때 (성공한 웹메일 · 앱 로그인 기록 — 90일 보존 안). 현황에만 쓴다 — + * 앱만 쓰는 직원이 로그인한 적 없어 보이지 않게 앱(계약 1절 "앱 로그인")도 센다. 메일 프로그램(IMAP · SMTP)은 세지 않는다 + */ + async lastSignIns(emails: readonly string[]): Promise> { 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( diff --git a/backend/src/modules/system-mail/system-notices.spec.ts b/backend/src/modules/system-mail/system-notices.spec.ts index 618d809..e317371 100644 --- a/backend/src/modules/system-mail/system-notices.spec.ts +++ b/backend/src/modules/system-mail/system-notices.spec.ts @@ -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( '웹메일의 [설정] → [2단계 인증]에서 다시 켜 주세요.', diff --git a/backend/src/modules/system-mail/system-notices.ts b/backend/src/modules/system-mail/system-notices.ts index 958ad91..4b20eda 100644 --- a/backend/src/modules/system-mail/system-notices.ts +++ b/backend/src/modules/system-mail/system-notices.ts @@ -158,7 +158,7 @@ export function passwordResetNotice( '바뀐 것', '비밀번호가 임시 비밀번호로 바뀌었고, 처음 로그인할 때 새 비밀번호를 정하게 됩니다', ], - ['로그아웃', '모든 기기와 브라우저의 웹메일에서 로그아웃되었습니다'], + ['로그아웃', '모든 기기의 웹메일과 앱에서 로그아웃되었습니다'], [ '앱 비밀번호', '모두 지워져서 메일 프로그램은 새 앱 비밀번호로 다시 연결해야 합니다', @@ -190,7 +190,7 @@ export function twoFactorResetNotice( rows: [ ['시각', formatKstDateTime(at)], ['바뀐 것', '2단계 인증이 꺼져서 지금은 비밀번호만으로 로그인합니다'], - ['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 브라우저'], + ['그대로인 것', '비밀번호, 앱 비밀번호, 로그인된 기기'], ], todo: [ { text: '새 휴대폰에 인증 앱을 설치한 뒤 웹메일의 ' }, diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 24f32a6..028e900 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -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 { diff --git a/backend/src/shared/utils/openapi-security.spec.ts b/backend/src/shared/utils/openapi-security.spec.ts new file mode 100644 index 0000000..39b2418 --- /dev/null +++ b/backend/src/shared/utils/openapi-security.spec.ts @@ -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); + }); +}); diff --git a/backend/src/shared/utils/openapi-security.ts b/backend/src/shared/utils/openapi-security.ts new file mode 100644 index 0000000..43bb5c1 --- /dev/null +++ b/backend/src/shared/utils/openapi-security.ts @@ -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; +} diff --git a/backend/src/shared/utils/user-agent.spec.ts b/backend/src/shared/utils/user-agent.spec.ts index f66774c..a5e6619 100644 --- a/backend/src/shared/utils/user-agent.spec.ts +++ b/backend/src/shared/utils/user-agent.spec.ts @@ -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, + }); + } + }); +}); diff --git a/backend/src/shared/utils/user-agent.ts b/backend/src/shared/utils/user-agent.ts index cbf9cf6..b7eccab 100644 --- a/backend/src/shared/utils/user-agent.ts +++ b/backend/src/shared/utils/user-agent.ts @@ -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, + }; +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index 8d9bbdb..bdce710 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -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(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(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(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(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(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(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(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(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>>(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>>(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>>(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';