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:
parent
477f35293e
commit
6618137da5
@ -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']);
|
||||
}
|
||||
|
||||
/** 当前租户品牌(仅需登录)。 */
|
||||
|
||||
@ -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,18 +303,20 @@ 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({
|
||||
where: { tenantCode, userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
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 },
|
||||
});
|
||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||
// 过滤已删除角色(与 resolveAuth 保持一致)
|
||||
const activeRoles = roleCodes.length
|
||||
@ -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> {
|
||||
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;
|
||||
|
||||
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);
|
||||
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 },
|
||||
});
|
||||
orgCode = u?.orgCode ?? null;
|
||||
const roleRows = await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode: user.tenantCode, userId: user.userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
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,12 +476,64 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/** 聚合权限码与超管标志。 */
|
||||
private async resolveAuth(tenantCode: string, userId: string): Promise<PermissionSet> {
|
||||
const roleRows = await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode, userId },
|
||||
select: { roleCode: true },
|
||||
/** 平台超管是否持有 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, switched = false): Promise<PermissionSet> {
|
||||
const roleRows = switched
|
||||
? []
|
||||
: await this.prisma.client.userRole.findMany({
|
||||
where: { tenantCode, userId },
|
||||
select: { roleCode: true },
|
||||
});
|
||||
const roleCodes = roleRows.map((r) => r.roleCode);
|
||||
// 过滤已删除角色(软删角色不参与权限聚合)
|
||||
const activeRoles = roleCodes.length
|
||||
@ -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: {
|
||||
|
||||
@ -65,3 +65,10 @@ export class RefreshDto {
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class SwitchTenantDto {
|
||||
@ApiProperty({ description: '目标租户编码' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
tenantCode!: string;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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('无权限');
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -6,6 +6,8 @@ export interface RequestContext {
|
||||
tenantCode: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
/** 是否平台超管(含跨租户切换后的超管)。 */
|
||||
super: boolean;
|
||||
dataScope: DataScope;
|
||||
orgCode: string | null;
|
||||
/** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user