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>
This commit is contained in:
liuyun 2026-10-12 00:00:38 +08:00
parent 477f35293e
commit 6618137da5
7 changed files with 143 additions and 44 deletions

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common'; import { Body, Controller, Get, Patch, Post, Req, UnauthorizedException } from '@nestjs/common';
import type { Request } from 'express'; import type { Request } from 'express';
import { AuthService } from './auth.service.js'; import { AuthService } from './auth.service.js';
import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto } from './dto.js'; import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto, SwitchTenantDto } from './dto.js';
import { Public } from './decorators.js'; import { Public } from './decorators.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
@ -38,7 +38,7 @@ export class AuthController {
menus() { menus() {
const ctx = getRequestContext(); const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录'); if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getMenus(ctx.tenantCode, ctx.userId); return this.auth.getMenus(ctx.tenantCode, ctx.userId, ctx.super);
} }
/** 当前用户权限码(含超管标志),供前端按钮/路由级权限控制(仅需登录)。 */ /** 当前用户权限码(含超管标志),供前端按钮/路由级权限控制(仅需登录)。 */
@ -46,7 +46,15 @@ export class AuthController {
permissions() { permissions() {
const ctx = getRequestContext(); const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录'); if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId); return this.auth.getPermissionSet(ctx.tenantCode, ctx.userId, ctx.super);
}
/** 平台超管切换租户(仅需登录;内部校验调用者为平台超管)。 */
@Post('switch-tenant')
switchTenant(@Body() dto: SwitchTenantDto, @Req() req: Request) {
const ctx = getRequestContext();
if (!ctx) throw new UnauthorizedException('未登录');
return this.auth.switchTenant(ctx.userId, dto, req.ip, req.headers['user-agent']);
} }
/** 当前租户品牌(仅需登录)。 */ /** 当前租户品牌(仅需登录)。 */

View File

@ -1,10 +1,10 @@
import { BadRequestException, ConflictException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ConflictException, ForbiddenException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service.js'; import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js'; import { RedisService } from '../common/redis/redis.service.js';
import { LoginDto, RefreshDto, RegisterDto, ChangePasswordDto } from './dto.js'; import { ChangePasswordDto, LoginDto, RefreshDto, RegisterDto, SwitchTenantDto } from './dto.js';
import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js'; import { resolveDataScope, resolveVisibleOrgs } from '../common/data-scope.js';
import type { DataScope } from '../common/enums.js'; import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js'; import type { RequestContext } from '../common/context/request-context.js';
@ -303,15 +303,17 @@ export class AuthService {
} }
/** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */ /** 读取用户权限集;缓存缺失时重新聚合并回填(Redis 重启自愈)。 */
async getPermissionSet(tenantCode: string, userId: string): Promise<PermissionSet> { async getPermissionSet(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId)); const cached = await this.redis.getJson<PermissionSet>(this.permKey(tenantCode, userId));
if (cached) return cached; if (cached) return cached;
return this.writePermissionCache(tenantCode, userId); return this.writePermissionCache(tenantCode, userId, switched);
} }
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */ /** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> { async getMenus(tenantCode: string, userId: string, switched = false): Promise<MenuNode[]> {
const roleRows = await this.prisma.client.userRole.findMany({ const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId }, where: { tenantCode, userId },
select: { roleCode: true }, select: { roleCode: true },
}); });
@ -324,7 +326,7 @@ export class AuthService {
}) })
: []; : [];
const activeRoleCodes = activeRoles.map((r) => r.code); const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE); const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const rows = isSuper const rows = isSuper
? await this.prisma.client.resource.findMany({ ? await this.prisma.client.resource.findMany({
@ -368,19 +370,23 @@ export class AuthService {
} }
/** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */ /** 由 RequestContextInterceptor 调用,构建当前请求的租户/用户/数据权限上下文。 */
async buildContext(user: { userId: string; tenantCode: string; username: string }): Promise<RequestContext> { async buildContext(user: { userId: string; tenantCode: string; username: string; super?: boolean }): Promise<RequestContext> {
const switched = user.super ?? false;
let orgCode: string | null = null;
let roleCodes: string[] = [];
if (!switched) {
const u = await this.prisma.client.user.findFirst({ const u = await this.prisma.client.user.findFirst({
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null }, where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
select: { orgCode: true }, select: { orgCode: true },
}); });
const orgCode = u?.orgCode ?? null; orgCode = u?.orgCode ?? null;
const roleRows = await this.prisma.client.userRole.findMany({ const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode: user.tenantCode, userId: user.userId }, where: { tenantCode: user.tenantCode, userId: user.userId },
select: { roleCode: true }, select: { roleCode: true },
}); });
const roleCodes = roleRows.map((r) => r.roleCode); roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE); }
const isSuper = switched || roleCodes.includes(SUPER_ROLE);
const roles = roleCodes.length const roles = roleCodes.length
? await this.prisma.client.role.findMany({ ? await this.prisma.client.role.findMany({
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null }, where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
@ -398,7 +404,15 @@ export class AuthService {
roleCodes, roleCodes,
}); });
return { tenantCode: user.tenantCode, userId: user.userId, username: user.username, dataScope, orgCode, orgCodes }; return {
tenantCode: user.tenantCode,
userId: user.userId,
username: user.username,
super: isSuper,
dataScope,
orgCode,
orgCodes,
};
} }
private permKey(tenantCode: string, userId: string): string { private permKey(tenantCode: string, userId: string): string {
@ -410,8 +424,10 @@ export class AuthService {
tenantCode: string, tenantCode: string,
userId: string, userId: string,
tokenVersion: number, tokenVersion: number,
switched = false,
): Promise<{ active: boolean; mustChangePassword: boolean }> { ): Promise<{ active: boolean; mustChangePassword: boolean }> {
const key = userStateCacheKey(tenantCode, userId); const homeTenant = switched ? PLATFORM_TENANT_CODE : tenantCode;
const key = userStateCacheKey(homeTenant, userId);
let state = await this.redis.getJson<{ let state = await this.redis.getJson<{
status: string; status: string;
tokenVersion: number; tokenVersion: number;
@ -420,7 +436,7 @@ export class AuthService {
}>(key); }>(key);
if (!state) { if (!state) {
const user = await this.prisma.client.user.findFirst({ const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null }, where: { tenantCode: homeTenant, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true }, select: { status: true, tokenVersion: true, lockedAt: true, mustChangePassword: true },
}); });
if (!user) return { active: false, mustChangePassword: false }; if (!user) return { active: false, mustChangePassword: false };
@ -460,9 +476,61 @@ export class AuthService {
}; };
} }
/** 平台超管是否持有 super_admin 角色(查平台租户,不随切换租户变化)。 */
private async isPlatformSuperAdmin(userId: string): Promise<boolean> {
const row = await this.prisma.raw.userRole.findFirst({
where: { tenantCode: PLATFORM_TENANT_CODE, userId, roleCode: SUPER_ROLE },
select: { userId: true },
});
return row != null;
}
/** 平台超管切入任意租户:签发 super=true 的新 token(sub 保持超管真实 userId)。 */
async switchTenant(
userId: string,
dto: SwitchTenantDto,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; tenantCode: string; tenantName: string }> {
if (!(await this.isPlatformSuperAdmin(userId))) {
throw new ForbiddenException('仅平台超级管理员可切换租户');
}
const admin = await this.prisma.raw.user.findFirst({
where: { tenantCode: PLATFORM_TENANT_CODE, id: userId, deletedAt: null },
});
if (!admin || admin.status !== 'enabled' || admin.lockedAt) {
throw new BusinessException(ErrorCodes.AUTH_DISABLED, '平台超管账号不可用', HttpStatus.UNAUTHORIZED);
}
const tenant = await this.prisma.raw.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') {
throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '目标租户不存在或已停用', HttpStatus.NOT_FOUND);
}
const tokens = await this.issue(
dto.tenantCode,
userId,
admin.username,
admin.tokenVersion,
{ sid: randomUUID(), loginAt: new Date(), ip: ip ?? null, userAgent: userAgent ?? null },
true,
);
await this.recordAudit({
tenantCode: dto.tenantCode,
userId,
username: admin.username,
module: 'auth',
action: 'switch-tenant',
status: 'success',
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return { ...tokens, tenantCode: dto.tenantCode, tenantName: tenant.name };
}
/** 聚合权限码与超管标志。 */ /** 聚合权限码与超管标志。 */
private async resolveAuth(tenantCode: string, userId: string): Promise<PermissionSet> { private async resolveAuth(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const roleRows = await this.prisma.client.userRole.findMany({ const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId }, where: { tenantCode, userId },
select: { roleCode: true }, select: { roleCode: true },
}); });
@ -475,7 +543,7 @@ export class AuthService {
}) })
: []; : [];
const activeRoleCodes = activeRoles.map((r) => r.code); const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE); const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const resourceRows = activeRoleCodes.length const resourceRows = activeRoleCodes.length
? await this.prisma.client.roleResource.findMany({ ? await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: activeRoleCodes } }, where: { tenantCode, roleCode: { in: activeRoleCodes } },
@ -495,8 +563,8 @@ export class AuthService {
} }
/** 聚合并写入权限缓存(登录/刷新时调用)。 */ /** 聚合并写入权限缓存(登录/刷新时调用)。 */
private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> { private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId); const set = await this.resolveAuth(tenantCode, userId, switched);
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL); await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
return set; return set;
} }
@ -538,8 +606,16 @@ export class AuthService {
username: string, username: string,
tokenVersion: number, tokenVersion: number,
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null }, session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
switched = false,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion, sid: session.sid }); const accessToken = this.jwt.sign({
sub: userId,
tenantCode,
username,
ver: tokenVersion,
sid: session.sid,
...(switched ? { super: true } : {}),
});
const jti = randomUUID(); const jti = randomUUID();
await this.prisma.client.refreshToken.create({ await this.prisma.client.refreshToken.create({
data: { data: {

View File

@ -65,3 +65,10 @@ export class RefreshDto {
@IsNotEmpty() @IsNotEmpty()
refreshToken!: string; refreshToken!: string;
} }
export class SwitchTenantDto {
@ApiProperty({ description: '目标租户编码' })
@IsString()
@IsNotEmpty()
tenantCode!: string;
}

View File

@ -10,6 +10,7 @@ export interface AuthUser {
userId: string; userId: string;
tenantCode: string; tenantCode: string;
username: string; username: string;
super?: boolean;
} }
@Injectable() @Injectable()
@ -34,8 +35,8 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request); const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('未登录'); if (!token) throw new UnauthorizedException('未登录');
try { try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string }>(token); 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); const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0, payload.super ?? false);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效'); if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) { if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED); throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED);
@ -43,7 +44,12 @@ export class JwtAuthGuard implements CanActivate {
if (mustChangePassword && !this.isAllowedWhileMustChange(request)) { if (mustChangePassword && !this.isAllowedWhileMustChange(request)) {
throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN); throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN);
} }
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser; request.user = {
userId: payload.sub,
tenantCode: payload.tenantCode,
username: payload.username,
super: payload.super ?? false,
} satisfies AuthUser;
return true; return true;
} catch (e) { } catch (e) {
if (e instanceof HttpException) throw e; if (e instanceof HttpException) throw e;

View File

@ -18,10 +18,10 @@ export class PermissionsGuard implements CanActivate {
if (!required || required.length === 0) return true; if (!required || required.length === 0) return true;
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user = request.user as { userId: string; tenantCode: string } | undefined; const user = request.user as { userId: string; tenantCode: string; super?: boolean } | undefined;
if (!user) throw new ForbiddenException('无权限'); if (!user) throw new ForbiddenException('无权限');
const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId); const { super: isSuper, permissions } = await this.auth.getPermissionSet(user.tenantCode, user.userId, user.super ?? false);
if (isSuper) return true; if (isSuper) return true;
if (required.every((code) => permissions.includes(code))) return true; if (required.every((code) => permissions.includes(code))) return true;
throw new ForbiddenException('无权限'); throw new ForbiddenException('无权限');

View File

@ -13,7 +13,7 @@ export class RequestContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const user = request.user as { userId: string; tenantCode: string; username: string } | undefined; const user = request.user as { userId: string; tenantCode: string; username: string; super?: boolean } | undefined;
if (!user) return next.handle(); if (!user) return next.handle();
return from(this.auth.buildContext(user)).pipe( return from(this.auth.buildContext(user)).pipe(

View File

@ -6,6 +6,8 @@ export interface RequestContext {
tenantCode: string; tenantCode: string;
userId: string; userId: string;
username: string; username: string;
/** 是否平台超管(含跨租户切换后的超管)。 */
super: boolean;
dataScope: DataScope; dataScope: DataScope;
orgCode: string | null; orgCode: string | null;
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */ /** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */