diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index ed0eed3..00635ed 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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']); } /** 当前租户品牌(仅需登录)。 */ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2833f50..15bca7c 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + async getPermissionSet(tenantCode: string, userId: string, switched = false): Promise { const cached = await this.redis.getJson(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 { - const roleRows = await this.prisma.client.userRole.findMany({ - where: { tenantCode, userId }, - select: { roleCode: true }, - }); + async getMenus(tenantCode: string, userId: string, switched = false): Promise { + 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 { - 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 { + 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 { - const roleRows = await this.prisma.client.userRole.findMany({ - where: { tenantCode, userId }, - select: { roleCode: true }, + /** 平台超管是否持有 super_admin 角色(查平台租户,不随切换租户变化)。 */ + private async isPlatformSuperAdmin(userId: string): Promise { + 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 { + 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 { - const set = await this.resolveAuth(tenantCode, userId); + private async writePermissionCache(tenantCode: string, userId: string, switched = false): Promise { + 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: { diff --git a/src/auth/dto.ts b/src/auth/dto.ts index 4f5112d..a958281 100644 --- a/src/auth/dto.ts +++ b/src/auth/dto.ts @@ -65,3 +65,10 @@ export class RefreshDto { @IsNotEmpty() refreshToken!: string; } + +export class SwitchTenantDto { + @ApiProperty({ description: '目标租户编码' }) + @IsString() + @IsNotEmpty() + tenantCode!: string; +} diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 553778e..c965286 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -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; diff --git a/src/auth/permissions.guard.ts b/src/auth/permissions.guard.ts index 6166eb0..4fa5d0e 100644 --- a/src/auth/permissions.guard.ts +++ b/src/auth/permissions.guard.ts @@ -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('无权限'); diff --git a/src/auth/request-context.interceptor.ts b/src/auth/request-context.interceptor.ts index b51005d..e49dc64 100644 --- a/src/auth/request-context.interceptor.ts +++ b/src/auth/request-context.interceptor.ts @@ -13,7 +13,7 @@ export class RequestContextInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { 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( diff --git a/src/common/context/request-context.ts b/src/common/context/request-context.ts index ae7c5b9..38edacb 100644 --- a/src/common/context/request-context.ts +++ b/src/common/context/request-context.ts @@ -6,6 +6,8 @@ export interface RequestContext { tenantCode: string; userId: string; username: string; + /** 是否平台超管(含跨租户切换后的超管)。 */ + super: boolean; dataScope: DataScope; orgCode: string | null; /** 数据权限可见的部门 code 集合;空数组表示「全部可见」 */