背景: - 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、 权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。 改动: - packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role) 与 user:view 权限点;写接口 strict 校验。 - apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/ user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh 旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。 - apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403; usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。 - e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。 - openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。 影响面: - 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。 - 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。 - 平台导航「用户与组织」由 /platform/users 调整为 /users。 回滚方式: - 代码整体回滚本提交;迁移为 expand,回滚代码不需删表; - 本地如需彻底回退按实施报告 §6 处理。 验证证据: - pnpm format:check / lint / typecheck 通过; - pnpm test:shared 95 / api 60 / web 55; - pnpm test:int:7 个(真实 PG/Redis); - A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6; - 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
1 parent
f0d218bd99
commit
4d9eb980af
120 files changed
+13112
-314
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { defer, type Observable } from 'rxjs';
|
||||
import { tap } from 'rxjs';
|
||||
|
||||
import { AUDIT_ACTION_KEY } from './decorators';
|
||||
import {
|
||||
buildRequestContext,
|
||||
type RequestContext,
|
||||
RequestContextService,
|
||||
type RequestWithTrace,
|
||||
} from './request-context';
|
||||
|
||||
interface AuditParams {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface AuditPayload {
|
||||
action: string;
|
||||
result: 'success' | 'failure';
|
||||
actorId: string | null;
|
||||
account: string | null;
|
||||
targetId: string | null;
|
||||
traceId: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function actionOf(context: ExecutionContext): string | null {
|
||||
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
|
||||
return typeof action === 'string' ? action : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局请求上下文 + @Audit 结构化日志。
|
||||
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('Audit');
|
||||
|
||||
constructor(
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<RequestWithTrace & { params?: AuditParams }>();
|
||||
const ctx = buildRequestContext(request);
|
||||
const action = actionOf(context);
|
||||
const targetId = request.params?.id ?? null;
|
||||
|
||||
return defer(() =>
|
||||
this.requestContext.run(ctx, () => {
|
||||
if (action === null) {
|
||||
return next.handle();
|
||||
}
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
this.emit(ctx, action, 'success', targetId);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.emit(ctx, action, 'failure', targetId, error);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private emit(
|
||||
ctx: RequestContext,
|
||||
action: string,
|
||||
result: AuditPayload['result'],
|
||||
targetId: string | null,
|
||||
error?: unknown,
|
||||
): void {
|
||||
const payload: AuditPayload = {
|
||||
action,
|
||||
result,
|
||||
actorId: ctx.userId,
|
||||
account: ctx.account,
|
||||
targetId,
|
||||
traceId: ctx.traceId,
|
||||
durationMs: Date.now() - ctx.startedAt,
|
||||
...(error === undefined
|
||||
? {}
|
||||
: { error: error instanceof Error ? error.message : '未知错误' }),
|
||||
};
|
||||
this.logger.log(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/** 供 @Audit 断言测试使用:读取处理器上的审计动作名 */
|
||||
export function auditActionOf(handler: unknown): string | null {
|
||||
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, handler as object) as unknown;
|
||||
return typeof action === 'string' ? action : null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
|
||||
export interface JwtAccessPayload {
|
||||
sub: string;
|
||||
typ: 'access';
|
||||
}
|
||||
|
||||
/** refresh token 载荷:jti 对应 refresh_tokens 主键,fam 为旋转家族(重放时整族吊销) */
|
||||
export interface JwtRefreshPayload {
|
||||
sub: string;
|
||||
typ: 'refresh';
|
||||
jti: string;
|
||||
fam: string;
|
||||
}
|
||||
|
||||
/** 守卫解析出的鉴权上下文(挂在 request.auth,并写入 AsyncLocalStorage) */
|
||||
export interface AuthContext {
|
||||
userId: string;
|
||||
account: string;
|
||||
status: UserStatus;
|
||||
roles: RoleSummary[];
|
||||
permissions: string[];
|
||||
dataScope: DataScope;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends FastifyRequest {
|
||||
auth?: AuthContext;
|
||||
}
|
||||
|
||||
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
|
||||
export interface AuthRequestMeta {
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
traceId: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'auth:public';
|
||||
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const PERMISSIONS_KEY = 'auth:permissions';
|
||||
/** 声明接口所需权限点(任一满足即通过,多个权限点之间为 OR) */
|
||||
export const RequirePermissions = (...permissions: string[]): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const AUDIT_ACTION_KEY = 'audit:action';
|
||||
/** 关键动作审计:先写结构化日志(A5 落库与检索接审计表后再扩展) */
|
||||
export const Audit = (action: string): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(AUDIT_ACTION_KEY, action);
|
||||
|
||||
/** 取当前登录上下文;未登录(@Public 接口)返回 null */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthContext | null => {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.auth ?? null;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
import { ErrorCode, SPECIAL_PERMISSIONS } from '@lingxi/shared';
|
||||
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
|
||||
'/api/v1/me',
|
||||
'/api/v1/auth/change-password',
|
||||
'/api/v1/auth/logout',
|
||||
'/api/v1/auth/refresh',
|
||||
];
|
||||
|
||||
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
|
||||
|
||||
function extractBearer(header: string | undefined): string | null {
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
const token = header.slice('Bearer '.length).trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
function isMustChangeAllowed(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url;
|
||||
return MUST_CHANGE_ALLOWED_PATHS.includes(path);
|
||||
}
|
||||
|
||||
function isPublic(reflector: Reflector, context: ExecutionContext): boolean {
|
||||
const value: unknown = reflector.getAllAndOverride(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function requiredPermissions(reflector: Reflector, context: ExecutionContext): string[] {
|
||||
const value: unknown = reflector.getAllAndOverride(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((code): code is string => typeof code === 'string');
|
||||
}
|
||||
|
||||
function authOf(context: ExecutionContext): AuthContext | null {
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
return request.auth ?? null;
|
||||
}
|
||||
|
||||
function requireAuth(context: ExecutionContext): AuthContext {
|
||||
const auth = authOf(context);
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1) JWT 校验 → 每请求解析权限 → 强制改密拦截。
|
||||
* 守卫顺序由 SecurityModule 的 APP_GUARD 注册顺序保证:
|
||||
* JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(Reflector) private readonly reflector: Reflector,
|
||||
@Inject(TokenService) private readonly tokens: TokenService,
|
||||
@Inject(PermissionService) private readonly permissions: PermissionService,
|
||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const token = extractBearer(request.headers.authorization);
|
||||
if (token === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
const payload = this.tokens.verifyAccess(token);
|
||||
const auth = await this.permissions.resolveAuth(payload.sub);
|
||||
if (auth === null) {
|
||||
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||
}
|
||||
if (auth.status === 'disabled') {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
|
||||
}
|
||||
if (auth.mustChangePassword && !isMustChangeAllowed(request.url)) {
|
||||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '请先修改初始密码' });
|
||||
}
|
||||
request.auth = auth;
|
||||
this.requestContext.patch({
|
||||
userId: auth.userId,
|
||||
account: auth.account,
|
||||
permissions: auth.permissions,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 2) 普通权限点校验;特殊权限留给 SpecialPermissionGuard(40301 与 40303 分开) */
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const normal = requiredPermissions(this.reflector, context).filter(
|
||||
(code) => !SPECIAL_PERMISSION_SET.has(code),
|
||||
);
|
||||
if (normal.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = normal.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 3) 特殊权限(导出 / AI 配置 / 审计导出 / 行业发布 / 客户字段审批)单独校验 */
|
||||
@Injectable()
|
||||
export class SpecialPermissionGuard implements CanActivate {
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
if (isPublic(this.reflector, context)) {
|
||||
return true;
|
||||
}
|
||||
const special = requiredPermissions(this.reflector, context).filter((code) =>
|
||||
SPECIAL_PERMISSION_SET.has(code),
|
||||
);
|
||||
if (special.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const auth = requireAuth(context);
|
||||
const missing = special.filter((code) => !auth.permissions.includes(code));
|
||||
if (missing.length > 0) {
|
||||
throw new AppException({
|
||||
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
details: { missing },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
|
||||
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScopeGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const auth = authOf(context);
|
||||
if (auth !== null && auth.dataScope.type !== 'all') {
|
||||
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { PRISMA } from '../../infra/tokens';
|
||||
import type { AuthContext } from './auth.types';
|
||||
|
||||
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
|
||||
export type UserAuthRecord = Prisma.UserGetPayload<{
|
||||
include: {
|
||||
department: { include: { parent: true } };
|
||||
roles: { include: { role: { include: { permissions: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
|
||||
|
||||
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
|
||||
return {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
isSystem: role.isSystem,
|
||||
...(role.description.length > 0 ? { description: role.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
|
||||
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
|
||||
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
|
||||
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
|
||||
}
|
||||
|
||||
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
|
||||
return user.roles.filter((item) => item.role.status === 'ACTIVE');
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
|
||||
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
||||
|
||||
async resolveAuth(userId: string): Promise<AuthContext | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return null;
|
||||
}
|
||||
return this.toAuthContext(user);
|
||||
}
|
||||
|
||||
async findAuthRecord(userId: string): Promise<UserAuthRecord | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
department: { include: { parent: true } },
|
||||
roles: { include: { role: { include: { permissions: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toAuthContext(user: UserAuthRecord): AuthContext {
|
||||
const roles = activeRolesOf(user);
|
||||
const permissions = [
|
||||
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
|
||||
].sort();
|
||||
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
|
||||
return {
|
||||
userId: user.id,
|
||||
account: user.account,
|
||||
status,
|
||||
roles: roles.map((item) => toRoleSummary(item.role)),
|
||||
permissions,
|
||||
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
import { ulid } from '@lingxi/shared';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { AuthenticatedRequest } from './auth.types';
|
||||
|
||||
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
|
||||
export interface RequestContext {
|
||||
traceId: string;
|
||||
userId: string | null;
|
||||
account: string | null;
|
||||
permissions: string[] | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
method: string;
|
||||
url: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export type RequestWithTrace = AuthenticatedRequest & { id?: string };
|
||||
|
||||
function traceIdOf(request: RequestWithTrace): string {
|
||||
return typeof request.id === 'string' && request.id.length > 0 ? request.id : ulid();
|
||||
}
|
||||
|
||||
/** 由请求构造上下文(与 Pino requestId 同源,x-request-id 透传) */
|
||||
export function buildRequestContext(request: RequestWithTrace): RequestContext {
|
||||
const auth = request.auth;
|
||||
return {
|
||||
traceId: traceIdOf(request),
|
||||
userId: auth?.userId ?? null,
|
||||
account: auth?.account ?? null,
|
||||
permissions: auth?.permissions ?? null,
|
||||
ip: request.ip.length > 0 ? request.ip : null,
|
||||
userAgent: request.headers['user-agent'] ?? null,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
|
||||
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
|
||||
*/
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
private readonly storage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
run<T>(context: RequestContext, callback: () => T): T {
|
||||
return this.storage.run(context, callback);
|
||||
}
|
||||
|
||||
get(): RequestContext | null {
|
||||
return this.storage.getStore() ?? null;
|
||||
}
|
||||
|
||||
patch(partial: Partial<RequestContext>): void {
|
||||
const store = this.storage.getStore();
|
||||
if (store === undefined) {
|
||||
return;
|
||||
}
|
||||
Object.assign(store, partial);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { AuditInterceptor } from './audit.interceptor';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
/**
|
||||
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
|
||||
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [
|
||||
RequestContextService,
|
||||
PermissionService,
|
||||
TokenService,
|
||||
AuditInterceptor,
|
||||
JwtAuthGuard,
|
||||
PermissionGuard,
|
||||
SpecialPermissionGuard,
|
||||
ScopeGuard,
|
||||
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useExisting: PermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
|
||||
{ provide: APP_GUARD, useExisting: ScopeGuard },
|
||||
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
|
||||
],
|
||||
exports: [RequestContextService, PermissionService, TokenService],
|
||||
})
|
||||
export class SecurityModule {}
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { testConfig } from '../../testing/test-app';
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { AuthContext, AuthenticatedRequest } from './auth.types';
|
||||
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
|
||||
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
|
||||
import type { UserAuthRecord } from './permission.service';
|
||||
import { PermissionService, resolveDataScope } from './permission.service';
|
||||
import type { RequestContextService } from './request-context';
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
const jwt = new JwtService({});
|
||||
const tokens = new TokenService(jwt, testConfig);
|
||||
|
||||
function contextOf(request: Partial<AuthenticatedRequest>): ExecutionContext {
|
||||
const handler = { name: 'handler' };
|
||||
const cls = { name: 'controller' };
|
||||
return {
|
||||
getHandler: () => handler,
|
||||
getClass: () => cls,
|
||||
switchToHttp: () => ({ getRequest: () => request }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function reflectorFor(metadata: Record<string, unknown>): {
|
||||
getAllAndOverride: (key: string) => unknown;
|
||||
} {
|
||||
return { getAllAndOverride: (key: string) => metadata[key] };
|
||||
}
|
||||
|
||||
function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
|
||||
return {
|
||||
userId: 'u1',
|
||||
account: 'chen.mo',
|
||||
status: 'active',
|
||||
roles: [],
|
||||
permissions: ['user:view'],
|
||||
dataScope: { type: 'department_and_children' },
|
||||
mustChangePassword: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectCode(fn: () => unknown, code: ErrorCode): void {
|
||||
try {
|
||||
fn();
|
||||
expect.unreachable('应抛出异常');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function authRecord(
|
||||
status: 'ACTIVE' | 'DISABLED' = 'ACTIVE',
|
||||
roleCodes: string[] = ['platform_admin'],
|
||||
): UserAuthRecord {
|
||||
return {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
name: '陈默',
|
||||
phone: '13866202046',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
passwordHash: 'hash',
|
||||
departmentId: 'd1',
|
||||
jobTitle: null,
|
||||
status,
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
|
||||
lastLoginIp: null,
|
||||
disabledAt: null,
|
||||
remark: null,
|
||||
createdBy: null,
|
||||
createdAt: new Date('2026-09-30T10:40:00.000Z'),
|
||||
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
|
||||
department: {
|
||||
id: 'd1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd0',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parent: {
|
||||
id: 'd0',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
roles: roleCodes.map((code, index) => ({
|
||||
userId: 'u1',
|
||||
roleId: `r${String(index)}`,
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: `r${String(index)}`,
|
||||
code,
|
||||
name: code,
|
||||
description: '',
|
||||
isSystem: code === 'super_admin',
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
permissions: [
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
|
||||
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
|
||||
],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TokenService', () => {
|
||||
it('access token 可签发并校验,载荷只含 userId', () => {
|
||||
const token = tokens.issueAccess('u1');
|
||||
const payload = tokens.verifyAccess(token);
|
||||
expect(payload.sub).toBe('u1');
|
||||
expect(payload.typ).toBe('access');
|
||||
});
|
||||
|
||||
it('refresh token 带 jti 与家族,类型不可混用', () => {
|
||||
const refresh = tokens.issueRefresh('u1', 'jti1', 'fam1');
|
||||
const payload = tokens.verifyRefresh(refresh);
|
||||
expect(payload.jti).toBe('jti1');
|
||||
expect(payload.fam).toBe('fam1');
|
||||
expectCode(() => tokens.verifyAccess(refresh), ErrorCode.SIGNATURE_INVALID);
|
||||
});
|
||||
|
||||
it('过期 token 返回 40102,篡改 token 返回 40103', () => {
|
||||
const expired = jwt.sign(
|
||||
{ sub: 'u1', typ: 'access' },
|
||||
{ secret: testConfig.jwtAccessSecret, expiresIn: -10 },
|
||||
);
|
||||
expectCode(() => tokens.verifyAccess(expired), ErrorCode.TOKEN_EXPIRED);
|
||||
expectCode(
|
||||
() => tokens.verifyAccess(`${tokens.issueAccess('u1')}x`),
|
||||
ErrorCode.SIGNATURE_INVALID,
|
||||
);
|
||||
expectCode(() => tokens.verifyAccess('not-a-jwt'), ErrorCode.SIGNATURE_INVALID);
|
||||
});
|
||||
|
||||
it('refresh 哈希稳定且 access TTL 来自配置', () => {
|
||||
expect(tokens.hashToken('abc')).toBe(tokens.hashToken('abc'));
|
||||
expect(tokens.hashToken('abc')).not.toBe(tokens.hashToken('abd'));
|
||||
expect(tokens.accessTtlSeconds).toBe(900);
|
||||
expect(tokens.refreshTtlSeconds).toBe(604800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionService / resolveDataScope', () => {
|
||||
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
|
||||
expect(resolveDataScope(['super_admin']).type).toBe('all');
|
||||
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
|
||||
});
|
||||
|
||||
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
|
||||
const service = new PermissionService({} as never);
|
||||
const context = service.toAuthContext(authRecord());
|
||||
expect(context.permissions).toEqual(['user:manage', 'user:view']);
|
||||
expect(context.status).toBe('active');
|
||||
expect(context.roles).toHaveLength(1);
|
||||
expect(context.dataScope.type).toBe('all');
|
||||
});
|
||||
|
||||
it('resolveAuth 用户不存在返回 null', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(null) } };
|
||||
const service = new PermissionService(prisma as never);
|
||||
await expect(service.resolveAuth('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('resolveAuth 读取关联角色并返回上下文', async () => {
|
||||
const prisma = {
|
||||
user: { findUnique: vi.fn().mockResolvedValue(authRecord('ACTIVE', ['designer'])) },
|
||||
};
|
||||
const service = new PermissionService(prisma as never);
|
||||
const context = await service.resolveAuth('u1');
|
||||
expect(context?.userId).toBe('u1');
|
||||
expect(context?.dataScope.type).toBe('department_and_children');
|
||||
expect(context?.roles[0]?.code).toBe('designer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const permissions = { resolveAuth: vi.fn() };
|
||||
const patch = vi.fn();
|
||||
const requestContext = { patch } as unknown as RequestContextService;
|
||||
|
||||
function guardOf(metadata: Record<string, unknown>): JwtAuthGuard {
|
||||
return new JwtAuthGuard(
|
||||
reflectorFor(metadata) as never,
|
||||
tokens,
|
||||
permissions as unknown as PermissionService,
|
||||
requestContext,
|
||||
);
|
||||
}
|
||||
|
||||
it('@Public 直接放行', async () => {
|
||||
const guard = guardOf({ [IS_PUBLIC_KEY]: true });
|
||||
await expect(guard.canActivate(contextOf({}))).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('缺少 Bearer token 返回 40101', async () => {
|
||||
const guard = guardOf({});
|
||||
await expectRejected(guard.canActivate(contextOf({ headers: {} })), ErrorCode.UNAUTHENTICATED);
|
||||
});
|
||||
|
||||
it('账号停用返回 40301,未改初始密码返回 40903', async () => {
|
||||
const disabled = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ status: 'disabled' }));
|
||||
await expectRejected(
|
||||
disabled.canActivate(
|
||||
contextOf({ headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` } }),
|
||||
),
|
||||
ErrorCode.PERMISSION_DENIED,
|
||||
);
|
||||
|
||||
const mustChange = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ mustChangePassword: true }));
|
||||
await expectRejected(
|
||||
mustChange.canActivate(
|
||||
contextOf({
|
||||
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
|
||||
url: '/api/v1/users',
|
||||
}),
|
||||
),
|
||||
ErrorCode.STATE_NOT_ALLOWED,
|
||||
);
|
||||
});
|
||||
|
||||
it('成功时挂载 request.auth 并写入上下文', async () => {
|
||||
const guard = guardOf({});
|
||||
permissions.resolveAuth.mockResolvedValueOnce(authFixture());
|
||||
const request = {
|
||||
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
|
||||
url: '/api/v1/users',
|
||||
} as unknown as AuthenticatedRequest;
|
||||
await guard.canActivate(contextOf(request));
|
||||
expect(request.auth?.userId).toBe('u1');
|
||||
expect(patch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
|
||||
const auth = authFixture();
|
||||
const request = { auth } as AuthenticatedRequest;
|
||||
|
||||
it('缺普通权限点返回 40301,特殊权限留给下一守卫', () => {
|
||||
const guard = new PermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['user:manage'] }) as never,
|
||||
);
|
||||
expect(() => guard.canActivate(contextOf(request))).toThrow(AppException);
|
||||
try {
|
||||
guard.canActivate(contextOf(request));
|
||||
} catch (error) {
|
||||
expect((error as AppException).code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
const specialOnly = new PermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
|
||||
);
|
||||
expect(specialOnly.canActivate(contextOf(request))).toBe(true);
|
||||
});
|
||||
|
||||
it('特殊权限缺失返回 40303,已授予则通过', () => {
|
||||
const missing = new SpecialPermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
|
||||
);
|
||||
expectCode(
|
||||
() => missing.canActivate(contextOf(request)),
|
||||
ErrorCode.SPECIAL_PERMISSION_REQUIRED,
|
||||
);
|
||||
|
||||
const granted = new SpecialPermissionGuard(
|
||||
reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never,
|
||||
);
|
||||
expect(granted.canActivate(contextOf(request))).toBe(true);
|
||||
});
|
||||
|
||||
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
|
||||
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
|
||||
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
|
||||
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
|
||||
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
|
||||
});
|
||||
|
||||
it('@Audit 元数据可读取', () => {
|
||||
const handler = (): void => undefined;
|
||||
Reflect.defineMetadata(AUDIT_ACTION_KEY, 'identity.user.create', handler);
|
||||
expect(Reflect.getMetadata(AUDIT_ACTION_KEY, handler)).toBe('identity.user.create');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { APP_CONFIG } from '../../config/config.module';
|
||||
import type { AppConfig } from '../../config/env';
|
||||
import { AppException } from '../errors/app-exception';
|
||||
import type { JwtAccessPayload, JwtRefreshPayload } from './auth.types';
|
||||
|
||||
type TokenType = 'access' | 'refresh';
|
||||
|
||||
function mapTokenError(error: unknown): AppException {
|
||||
if (error instanceof AppException) {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Error && error.name === 'TokenExpiredError') {
|
||||
return new AppException({ code: ErrorCode.TOKEN_EXPIRED });
|
||||
}
|
||||
return new AppException({ code: ErrorCode.SIGNATURE_INVALID });
|
||||
}
|
||||
|
||||
function assertTokenType<T extends { typ: TokenType }>(payload: T, expected: TokenType): T {
|
||||
if (payload.typ !== expected) {
|
||||
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '令牌类型不正确' });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 令牌签发与校验。
|
||||
* - access / refresh 使用不同密钥,避免 refresh 当 access 使用;
|
||||
* - refresh 载荷带 jti + fam,服务端 refresh_tokens 表保存哈希与吊销状态。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
@Inject(JwtService) private readonly jwt: JwtService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
issueAccess(userId: string): string {
|
||||
const payload: JwtAccessPayload = { sub: userId, typ: 'access' };
|
||||
return this.jwt.sign(payload, {
|
||||
secret: this.config.jwtAccessSecret,
|
||||
expiresIn: this.config.jwtAccessTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
issueRefresh(userId: string, jti: string, familyId: string): string {
|
||||
const payload: JwtRefreshPayload = { sub: userId, typ: 'refresh', jti, fam: familyId };
|
||||
return this.jwt.sign(payload, {
|
||||
secret: this.config.jwtRefreshSecret,
|
||||
expiresIn: this.config.jwtRefreshTtlSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
verifyAccess(token: string): JwtAccessPayload {
|
||||
return assertTokenType(this.decodeAccess(token), 'access');
|
||||
}
|
||||
|
||||
verifyRefresh(token: string): JwtRefreshPayload {
|
||||
return assertTokenType(this.decodeRefresh(token), 'refresh');
|
||||
}
|
||||
|
||||
hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
get accessTtlSeconds(): number {
|
||||
return this.config.jwtAccessTtlSeconds;
|
||||
}
|
||||
|
||||
get refreshTtlSeconds(): number {
|
||||
return this.config.jwtRefreshTtlSeconds;
|
||||
}
|
||||
|
||||
private decodeAccess(token: string): JwtAccessPayload {
|
||||
try {
|
||||
return this.jwt.verify<JwtAccessPayload>(token, { secret: this.config.jwtAccessSecret });
|
||||
} catch (error) {
|
||||
throw mapTokenError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeRefresh(token: string): JwtRefreshPayload {
|
||||
try {
|
||||
return this.jwt.verify<JwtRefreshPayload>(token, { secret: this.config.jwtRefreshSecret });
|
||||
} catch (error) {
|
||||
throw mapTokenError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user