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 type { Request } from 'express';
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 { getRequestContext } from '../common/context/request-context.js';
@ -38,7 +38,7 @@ export class AuthController {
menus() {
const ctx = getRequestContext();
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() {
const ctx = getRequestContext();
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 bcrypt from 'bcryptjs';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.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 type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js';
@ -303,15 +303,17 @@ export class AuthService {
}
/** 读取用户权限集;缓存缺失时重新聚合并回填(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));
if (cached) return cached;
return this.writePermissionCache(tenantCode, userId);
return this.writePermissionCache(tenantCode, userId, switched);
}
/** 当前用户的菜单树(超管返回全部启用菜单,否则按角色可见菜单 + 补祖先)。 */
async getMenus(tenantCode: string, userId: string): Promise<MenuNode[]> {
const roleRows = await this.prisma.client.userRole.findMany({
async getMenus(tenantCode: string, userId: string, switched = false): Promise<MenuNode[]> {
const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
@ -324,7 +326,7 @@ export class AuthService {
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const rows = isSuper
? await this.prisma.client.resource.findMany({
@ -368,19 +370,23 @@ export class AuthService {
}
/** 由 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({
where: { tenantCode: user.tenantCode, id: user.userId, deletedAt: null },
select: { orgCode: true },
});
const orgCode = u?.orgCode ?? null;
orgCode = u?.orgCode ?? null;
const roleRows = await this.prisma.client.userRole.findMany({
where: { tenantCode: user.tenantCode, userId: user.userId },
select: { roleCode: true },
});
const roleCodes = roleRows.map((r) => r.roleCode);
const isSuper = roleCodes.includes(SUPER_ROLE);
roleCodes = roleRows.map((r) => r.roleCode);
}
const isSuper = switched || roleCodes.includes(SUPER_ROLE);
const roles = roleCodes.length
? await this.prisma.client.role.findMany({
where: { tenantCode: user.tenantCode, code: { in: roleCodes }, deletedAt: null },
@ -398,7 +404,15 @@ export class AuthService {
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 {
@ -410,8 +424,10 @@ export class AuthService {
tenantCode: string,
userId: string,
tokenVersion: number,
switched = false,
): 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<{
status: string;
tokenVersion: number;
@ -420,7 +436,7 @@ export class AuthService {
}>(key);
if (!state) {
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 },
});
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> {
const roleRows = await this.prisma.client.userRole.findMany({
private async resolveAuth(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const roleRows = switched
? []
: await this.prisma.client.userRole.findMany({
where: { tenantCode, userId },
select: { roleCode: true },
});
@ -475,7 +543,7 @@ export class AuthService {
})
: [];
const activeRoleCodes = activeRoles.map((r) => r.code);
const isSuper = activeRoleCodes.includes(SUPER_ROLE);
const isSuper = switched || activeRoleCodes.includes(SUPER_ROLE);
const resourceRows = activeRoleCodes.length
? await this.prisma.client.roleResource.findMany({
where: { tenantCode, roleCode: { in: activeRoleCodes } },
@ -495,8 +563,8 @@ export class AuthService {
}
/** 聚合并写入权限缓存(登录/刷新时调用)。 */
private async writePermissionCache(tenantCode: string, userId: string): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId);
private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise<PermissionSet> {
const set = await this.resolveAuth(tenantCode, userId, switched);
await this.redis.setJson(this.permKey(tenantCode, userId), set, REFRESH_TTL);
return set;
}
@ -538,8 +606,16 @@ export class AuthService {
username: string,
tokenVersion: number,
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
switched = false,
): 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();
await this.prisma.client.refreshToken.create({
data: {

View File

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

View File

@ -10,6 +10,7 @@ export interface AuthUser {
userId: string;
tenantCode: string;
username: string;
super?: boolean;
}
@Injectable()
@ -34,8 +35,8 @@ export class JwtAuthGuard implements CanActivate {
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 }>(token);
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
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);
@ -43,7 +44,12 @@ export class JwtAuthGuard implements CanActivate {
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 } satisfies AuthUser;
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;

View File

@ -18,10 +18,10 @@ export class PermissionsGuard implements CanActivate {
if (!required || required.length === 0) return true;
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('无权限');
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 (required.every((code) => permissions.includes(code))) return true;
throw new ForbiddenException('无权限');

View File

@ -13,7 +13,7 @@ export class RequestContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
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();
return from(this.auth.buildContext(user)).pipe(

View File

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