feat: 记录登录审计上下文

- src/auth/auth.controller.ts: 登录时提取 requestId、来源 IP 和 user-agent 传入服务层
- src/auth/auth.service.ts: 登录成功审计补齐请求上下文,登录失败写入 USER_LOGIN_FAILED
- src/auth/auth.types.ts: 补充登录审计请求上下文类型
This commit is contained in:
湛兮
2026-06-12 06:10:16 +08:00
parent b305922a5b
commit 581e14cf30
3 changed files with 76 additions and 4 deletions
+23 -3
View File
@@ -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 { import {
ApiBody, ApiBody,
ApiCreatedResponse, ApiCreatedResponse,
@@ -20,7 +23,13 @@ import {
} from './auth.dto'; } from './auth.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator'; 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('登录鉴权') @ApiTags('登录鉴权')
@Controller('auth') @Controller('auth')
@@ -31,9 +40,10 @@ export class AuthController {
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
@ApiCreatedResponse({ description: '登录成功,返回登录令牌和当前账号信息。' }) @ApiCreatedResponse({ description: '登录成功,返回登录令牌和当前账号信息。' })
async login( async login(
@Req() request: RequestWithUser,
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput, @Body(new ZodValidationPipe(loginSchema)) body: LoginInput,
): Promise<LoginResult> { ): Promise<LoginResult> {
return this.authService.login(body); return this.authService.login(body, this.auditContextFor(request));
} }
@Get('me') @Get('me')
@@ -75,4 +85,14 @@ export class AuthController {
message: '已退出登录', 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,
};
}
} }
+40 -1
View File
@@ -1,3 +1,6 @@
/**
* 登录、密码和项目权限服务,统一维护账号状态与鉴权审计。
*/
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
@@ -15,6 +18,7 @@ import {
LoginInput, LoginInput,
} from './auth.dto'; } from './auth.dto';
import { import {
AuthRequestContext,
AuthenticatedUser, AuthenticatedUser,
LoginResult, LoginResult,
ProjectPermissionLevel, ProjectPermissionLevel,
@@ -41,14 +45,19 @@ export class AuthService implements OnModuleInit {
await this.userRepository.ensureSuperAdmin(passwordHash); await this.userRepository.ensureSuperAdmin(passwordHash);
} }
async login(input: LoginInput): Promise<LoginResult> { async login(
input: LoginInput,
context: AuthRequestContext = {},
): Promise<LoginResult> {
const user = await this.userRepository.findByAccount(input.account); const user = await this.userRepository.findByAccount(input.account);
if (!user) { if (!user) {
await this.recordLoginFailure(input.account, 'account_not_found', context);
throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401);
} }
if (user.status !== 'active') { if (user.status !== 'active') {
await this.recordLoginFailure(input.account, 'disabled', context, user);
throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403); throw new AppError('FORBIDDEN', '账号已被禁用,请联系超级管理员', 403);
} }
@@ -58,6 +67,7 @@ export class AuthService implements OnModuleInit {
); );
if (!passwordMatched) { if (!passwordMatched) {
await this.recordLoginFailure(input.account, 'invalid_password', context, user);
throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401); throw new AppError('AUTH_REQUIRED', '账号或密码错误', 401);
} }
@@ -68,6 +78,9 @@ export class AuthService implements OnModuleInit {
resourceId: user.id, resourceId: user.id,
actorId: user.id, actorId: user.id,
actorName: user.account, actorName: user.account,
requestId: context.requestId,
sourceIp: context.sourceIp,
userAgent: context.userAgent,
after: { after: {
role: user.role, role: user.role,
mustChangePassword: user.mustChangePassword, mustChangePassword: user.mustChangePassword,
@@ -230,6 +243,32 @@ export class AuthService implements OnModuleInit {
return user; return user;
} }
private async recordLoginFailure(
account: string,
reason: 'account_not_found' | 'disabled' | 'invalid_password',
context: AuthRequestContext,
user?: UserRecord,
): Promise<void> {
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 { private resolveSuperAdminInitialPassword(): string {
const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', { const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', {
infer: true, infer: true,
+13
View File
@@ -1,3 +1,6 @@
/**
* 登录鉴权模块的账号、权限和请求上下文类型。
*/
export type AuthRole = 'super_admin' | 'member'; export type AuthRole = 'super_admin' | 'member';
export type AuthStatus = 'active' | 'disabled'; export type AuthStatus = 'active' | 'disabled';
@@ -51,7 +54,17 @@ export type LoginResult = {
expiresAt: string; expiresAt: string;
}; };
export type AuthRequestContext = {
requestId?: string;
sourceIp?: string;
userAgent?: string;
};
export type RequestWithUser = { export type RequestWithUser = {
headers: Record<string, string | string[] | undefined>; headers: Record<string, string | string[] | undefined>;
ip?: string;
socket?: {
remoteAddress?: string;
};
user?: AuthenticatedUser; user?: AuthenticatedUser;
}; };