ec-nest/src/auth/jwt-auth.guard.ts
liuyun 6618137da5 feat(auth): 平台超管租户切换(super 标志 + switch-tenant)
- JWT 加 super 标志,sub 保持超管真实 userId,tenantCode 随切换
- isSuper 解析/checkUserActive/PermissionsGuard 支持跨租户超管
- POST /auth/switch-tenant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 00:00:38 +08:00

75 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}