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 { ScopeResolver } from './scope/scope-resolver.service'; 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 = new Set(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(); 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 { if (isPublic(this.reflector, context)) { return true; } const request = context.switchToHttp().getRequest(); 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); // 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释) if (!normal.some((code) => auth.permissions.includes(code))) { throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } }); } 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); if (!special.some((code) => auth.permissions.includes(code))) { throw new AppException({ code: ErrorCode.SPECIAL_PERMISSION_REQUIRED, details: { required: special }, }); } return true; } } /** * 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope` * 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。 * * 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」; * 公开接口(登录 / 健康检查)不解析。 */ @Injectable() export class ScopeGuard implements CanActivate { constructor( @Inject(Reflector) private readonly reflector: Reflector, @Inject(ScopeResolver) private readonly resolver: ScopeResolver, @Inject(RequestContextService) private readonly requestContext: RequestContextService, ) {} async canActivate(context: ExecutionContext): Promise { if (isPublic(this.reflector, context)) { return true; } const request = context.switchToHttp().getRequest(); const auth = authOf(context); if (auth === null) { return true; } const scope = await this.resolver.resolve(auth); request.scope = scope; this.requestContext.patch({ scope }); return true; } }