diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c5b8d41..e6a326c 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,7 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +/** + * 登录鉴权 HTTP 入口,负责校验入参并把请求上下文传给审计链路。 + */ +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBody, ApiCreatedResponse, @@ -20,7 +23,13 @@ import { } from './auth.dto'; import { AuthService } from './auth.service'; import { CurrentUser } from './current-user.decorator'; -import { AuthenticatedUser, LoginResult } from './auth.types'; +import { + AuthenticatedUser, + AuthRequestContext, + LoginResult, + RequestWithUser, +} from './auth.types'; +import { resolveRequestId } from '../common/http/request-id'; @ApiTags('登录鉴权') @Controller('auth') @@ -31,9 +40,10 @@ export class AuthController { @ApiBody({ type: LoginDto }) @ApiCreatedResponse({ description: '登录成功,返回登录令牌和当前账号信息。' }) async login( + @Req() request: RequestWithUser, @Body(new ZodValidationPipe(loginSchema)) body: LoginInput, ): Promise { - return this.authService.login(body); + return this.authService.login(body, this.auditContextFor(request)); } @Get('me') @@ -75,4 +85,14 @@ export class AuthController { message: '已退出登录', }; } + + private auditContextFor(request: RequestWithUser): AuthRequestContext { + const userAgent = request.headers['user-agent']; + + return { + requestId: resolveRequestId(request.headers), + sourceIp: request.ip ?? request.socket?.remoteAddress, + userAgent: Array.isArray(userAgent) ? userAgent[0] : userAgent, + }; + } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2b396ac..df5aa7a 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,3 +1,6 @@ +/** + * 登录、密码和项目权限服务,统一维护账号状态与鉴权审计。 + */ import { Injectable, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { AuditService } from '../audit/audit.service'; @@ -15,6 +18,7 @@ import { LoginInput, } from './auth.dto'; import { + AuthRequestContext, AuthenticatedUser, LoginResult, ProjectPermissionLevel, @@ -41,14 +45,19 @@ export class AuthService implements OnModuleInit { await this.userRepository.ensureSuperAdmin(passwordHash); } - async login(input: LoginInput): Promise { + async login( + input: LoginInput, + context: AuthRequestContext = {}, + ): Promise { const user = await this.userRepository.findByAccount(input.account); if (!user) { + await this.recordLoginFailure(input.account, 'account_not_found', context); throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); } if (user.status !== 'active') { + await this.recordLoginFailure(input.account, 'disabled', context, user); throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403); } @@ -58,6 +67,7 @@ export class AuthService implements OnModuleInit { ); if (!passwordMatched) { + await this.recordLoginFailure(input.account, 'invalid_password', context, user); throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); } @@ -68,6 +78,9 @@ export class AuthService implements OnModuleInit { resourceId: user.id, actorId: user.id, actorName: user.account, + requestId: context.requestId, + sourceIp: context.sourceIp, + userAgent: context.userAgent, after: { role: user.role, mustChangePassword: user.mustChangePassword, @@ -230,6 +243,32 @@ export class AuthService implements OnModuleInit { return user; } + private async recordLoginFailure( + account: string, + reason: 'account_not_found' | 'disabled' | 'invalid_password', + context: AuthRequestContext, + user?: UserRecord, + ): Promise { + await this.auditService.record({ + action: 'USER_LOGIN_FAILED', + resourceType: 'user', + resourceId: user?.id, + actorId: user?.id, + actorName: user?.account ?? account.trim(), + requestId: context.requestId, + sourceIp: context.sourceIp, + userAgent: context.userAgent, + after: { + reason, + role: user?.role, + status: user?.status, + }, + parameterDigest: { + account: account.trim(), + }, + }); + } + private resolveSuperAdminInitialPassword(): string { const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', { infer: true, diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts index f62459a..70d1aff 100644 --- a/src/auth/auth.types.ts +++ b/src/auth/auth.types.ts @@ -1,3 +1,6 @@ +/** + * 登录鉴权模块的账号、权限和请求上下文类型。 + */ export type AuthRole = 'super_admin' | 'member'; export type AuthStatus = 'active' | 'disabled'; @@ -51,7 +54,17 @@ export type LoginResult = { expiresAt: string; }; +export type AuthRequestContext = { + requestId?: string; + sourceIp?: string; + userAgent?: string; +}; + export type RequestWithUser = { headers: Record; + ip?: string; + socket?: { + remoteAddress?: string; + }; user?: AuthenticatedUser; };