feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 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:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
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;
},
);
+174
View File
@@ -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);
}
}
}