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:
@@ -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<LoginResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LoginResult> {
|
||||
async login(
|
||||
input: LoginInput,
|
||||
context: AuthRequestContext = {},
|
||||
): Promise<LoginResult> {
|
||||
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<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 {
|
||||
const password = this.config.get('SUPER_ADMIN_INITIAL_PASSWORD', {
|
||||
infer: true,
|
||||
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
ip?: string;
|
||||
socket?: {
|
||||
remoteAddress?: string;
|
||||
};
|
||||
user?: AuthenticatedUser;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user