- JWT 加 super 标志,sub 保持超管真实 userId,tenantCode 随切换 - isSuper 解析/checkUserActive/PermissionsGuard 支持跨租户超管 - POST /auth/switch-tenant Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||
import { Reflector } from '@nestjs/core';
|
||
import { JwtService } from '@nestjs/jwt';
|
||
import { IS_PUBLIC_KEY } from './decorators.js';
|
||
import { AuthService } from './auth.service.js';
|
||
import { BusinessException } from '../common/business-exception.js';
|
||
import { ErrorCodes } from '../common/error-codes.js';
|
||
|
||
export interface AuthUser {
|
||
userId: string;
|
||
tenantCode: string;
|
||
username: string;
|
||
super?: boolean;
|
||
}
|
||
|
||
@Injectable()
|
||
export class JwtAuthGuard implements CanActivate {
|
||
constructor(
|
||
private readonly jwt: JwtService,
|
||
private readonly reflector: Reflector,
|
||
private readonly auth: AuthService,
|
||
) {}
|
||
|
||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||
context.getHandler(),
|
||
context.getClass(),
|
||
]);
|
||
if (isPublic) return true;
|
||
|
||
const request = context.switchToHttp().getRequest();
|
||
const url: string = request.originalUrl ?? request.url ?? '';
|
||
if (url.startsWith('/docs')) return true;
|
||
|
||
const token = this.extractToken(request);
|
||
if (!token) throw new UnauthorizedException('未登录');
|
||
try {
|
||
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string; super?: boolean }>(token);
|
||
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0, payload.super ?? false);
|
||
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
||
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
|
||
throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED);
|
||
}
|
||
if (mustChangePassword && !this.isAllowedWhileMustChange(request)) {
|
||
throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN);
|
||
}
|
||
request.user = {
|
||
userId: payload.sub,
|
||
tenantCode: payload.tenantCode,
|
||
username: payload.username,
|
||
super: payload.super ?? false,
|
||
} satisfies AuthUser;
|
||
return true;
|
||
} catch (e) {
|
||
if (e instanceof HttpException) throw e;
|
||
throw new UnauthorizedException('token 无效或已过期');
|
||
}
|
||
}
|
||
|
||
private extractToken(request: { headers?: Record<string, string | undefined> }): string | undefined {
|
||
const auth = request.headers?.authorization;
|
||
if (!auth) return undefined;
|
||
const [type, token] = auth.split(' ');
|
||
return type === 'Bearer' && token ? token : undefined;
|
||
}
|
||
|
||
/** 待改密门禁白名单:修改本人密码 + 登出(登出目前 @Public,此处兜底防未来改注解)。 */
|
||
private isAllowedWhileMustChange(request: { method?: string; originalUrl?: string; url?: string }): boolean {
|
||
const url: string = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
||
if (request.method === 'PATCH' && url.endsWith('/auth/me/password')) return true;
|
||
if (request.method === 'POST' && url.endsWith('/auth/logout')) return true;
|
||
return false;
|
||
}
|
||
}
|