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 {
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user