From 513688a5e6e0387bb00a464d1ec7bdd7e2f6c594 Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 16:10:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20P0=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=B6=8A=E6=9D=83=E5=A0=B5=E6=BC=8F=EF=BC=88C1/H1-H6/H10/H11/H?= =?UTF-8?q?13/M1/M4/M6/M8-M11/M22=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台管理员模型:super_admin=平台超管、tenant_super_admin=租户顶层(保留码),tenant:* 仅平台可授予 - 软删改名回收唯一键 + P2002→409 - 删除角色级联清理关联 + 聚合过滤已删/停用角色资源 - tokenVersion 会话吊销(重置密码/停用/删除即时踢下线) - data_scope self 按 userId 过滤 + 详情数据权限校验 - 组织树防环、seed 不重置密码、日志脱敏补 phone/email - JWT 密钥强校验、pageSize 上限、DTO 枚举校验、Prisma 错误映射、授权事务化 Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 2 + prisma/schema.prisma | 1 + prisma/seed.ts | 9 ++-- src/auth/auth.module.ts | 10 +++- src/auth/auth.service.ts | 49 ++++++++++++++----- src/auth/jwt-auth.guard.ts | 9 +++- src/common/base-crud.service.ts | 29 ++++++++++- src/common/constants.ts | 24 +++++++++ src/common/data-scope.ts | 16 ++++-- src/common/filters/http-exception.filter.ts | 35 ++++++++++--- src/common/mask.ts | 3 ++ src/common/page.dto.ts | 3 +- src/dicts/dto.ts | 5 +- src/orgs/dto.ts | 5 +- src/orgs/orgs.service.ts | 30 ++++++++++-- src/resources/dto.ts | 7 +-- src/roles/dto.ts | 11 +++-- src/roles/roles.controller.ts | 2 +- src/roles/roles.service.ts | 45 ++++++++++++++--- src/tenants/dto.ts | 9 ++-- src/users/dto.ts | 7 +-- src/users/users.service.ts | 44 +++++++++++++++-- 22 files changed, 290 insertions(+), 65 deletions(-) create mode 100644 prisma/migrations/20261011150000_user_token_version/migration.sql create mode 100644 src/common/constants.ts diff --git a/prisma/migrations/20261011150000_user_token_version/migration.sql b/prisma/migrations/20261011150000_user_token_version/migration.sql new file mode 100644 index 0000000..97455c6 --- /dev/null +++ b/prisma/migrations/20261011150000_user_token_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `sys_user` ADD COLUMN `token_version` INT NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c8532cd..22bcd4d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,7 @@ model User { phone String? @db.VarChar(32) avatar String? @db.VarChar(255) status String @default("enabled") @db.VarChar(16) + tokenVersion Int @default(0) @map("token_version") orgCode String? @map("org_code") @db.VarChar(64) createdAt DateTime @default(now()) @map("created_at") createdBy String? @map("created_by") @db.VarChar(36) diff --git a/prisma/seed.ts b/prisma/seed.ts index 6d81ccd..0eaf9eb 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,11 +1,12 @@ import { PrismaClient } from '@prisma/client'; import bcrypt from 'bcryptjs'; import { Permissions } from '../src/common/permissions.js'; +import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE } from '../src/common/constants.js'; const prisma = new PrismaClient(); -const TENANT = 'touyun'; -const SUPER_ROLE = 'super_admin'; +const TENANT = PLATFORM_TENANT_CODE; +const SUPER_ROLE = PLATFORM_ROLE_CODE; const ADMIN_USERNAME = 'touyun'; const ADMIN_PASSWORD = 'Touyun@123'; @@ -141,11 +142,11 @@ async function main(): Promise { create: { tenantCode: TENANT, code: SUPER_ROLE, name: '超级管理员', dataScope: 'all', status: 'enabled' }, }); - // 4. 管理员用户 + // 4. 管理员用户(update 分支不重置密码/状态,避免重跑 seed 重置管理员密码或重新启用账号) const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10); const user = await prisma.user.upsert({ where: { tenantCode_username: { tenantCode: TENANT, username: ADMIN_USERNAME } }, - update: { passwordHash, nickname: '管理员', status: 'enabled' }, + update: { nickname: '管理员' }, create: { tenantCode: TENANT, username: ADMIN_USERNAME, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9032ab2..f68acf5 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,11 +5,19 @@ import { AuthService } from './auth.service.js'; import { AuditLogsModule } from '../audit-logs/audit-logs.module.js'; import { LoginLogsModule } from '../login-logs/login-logs.module.js'; +const jwtSecret = process.env.JWT_SECRET; +if (!jwtSecret) { + throw new Error('JWT_SECRET 环境变量未配置,拒绝启动'); +} +if (process.env.NODE_ENV === 'production' && (jwtSecret === 'dev-secret-change-me' || jwtSecret.length < 32)) { + throw new Error('生产环境 JWT_SECRET 过弱,请配置至少 32 位强密钥'); +} + @Module({ imports: [ JwtModule.register({ global: true, - secret: process.env.JWT_SECRET ?? 'dev-secret-change-me', + secret: jwtSecret, signOptions: { expiresIn: Number(process.env.JWT_ACCESS_TTL ?? 7200) }, }), AuditLogsModule, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index fda08b6..ae42873 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,10 +10,10 @@ import type { DataScope } from '../common/enums.js'; import type { RequestContext } from '../common/context/request-context.js'; import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; import { LoginLogsService } from '../login-logs/login-logs.service.js'; +import { PLATFORM_ROLE_CODE as SUPER_ROLE, permCacheKey, userStateCacheKey } from '../common/constants.js'; const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800); const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); -const SUPER_ROLE = 'super_admin'; export interface PermissionSet { super: boolean; @@ -115,7 +115,7 @@ export class AuthService { ip: ip ?? null, userAgent: userAgent ?? null, }); - return this.issue(dto.tenantCode, user.id, user.username); + return this.issue(dto.tenantCode, user.id, user.username, user.tokenVersion); } async refresh(dto: RefreshDto): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { @@ -134,7 +134,7 @@ export class AuthService { data: { revokedAt: new Date() }, }); await this.writePermissionCache(record.tenantCode, user.id); - return this.issue(record.tenantCode, user.id, user.username); + return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion); } async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise { @@ -251,7 +251,23 @@ export class AuthService { } private permKey(tenantCode: string, userId: string): string { - return `perm:${tenantCode}:${userId}`; + return permCacheKey(tenantCode, userId); + } + + /** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */ + async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise { + const key = userStateCacheKey(tenantCode, userId); + let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key); + if (!state) { + const user = await this.prisma.client.user.findFirst({ + where: { tenantCode, id: userId, deletedAt: null }, + select: { status: true, tokenVersion: true }, + }); + if (!user) return false; + state = { status: user.status, tokenVersion: user.tokenVersion }; + await this.redis.setJson(key, state, ACCESS_TTL); + } + return state.status === 'enabled' && state.tokenVersion === tokenVersion; } /** 聚合权限码与超管标志。 */ @@ -261,16 +277,26 @@ export class AuthService { select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); - const isSuper = roleCodes.includes(SUPER_ROLE); - const resourceRows = await this.prisma.client.roleResource.findMany({ - where: { tenantCode, roleCode: { in: roleCodes } }, - select: { resourceCode: true }, - }); + // 过滤已删除角色(软删角色不参与权限聚合) + const activeRoles = roleCodes.length + ? await this.prisma.client.role.findMany({ + where: { tenantCode, code: { in: roleCodes }, deletedAt: null }, + select: { code: true }, + }) + : []; + const activeRoleCodes = activeRoles.map((r) => r.code); + const isSuper = activeRoleCodes.includes(SUPER_ROLE); + const resourceRows = activeRoleCodes.length + ? await this.prisma.client.roleResource.findMany({ + where: { tenantCode, roleCode: { in: activeRoleCodes } }, + select: { resourceCode: true }, + }) + : []; const resourceCodes = [...new Set(resourceRows.map((r) => r.resourceCode))]; const permissions: string[] = resourceCodes.length ? ( await this.prisma.client.resource.findMany({ - where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, deletedAt: null }, + where: { tenantCode, code: { in: resourceCodes }, type: { in: ['button', 'api'] }, status: 'enabled', deletedAt: null }, select: { code: true }, }) ).map((r) => r.code) @@ -320,8 +346,9 @@ export class AuthService { tenantCode: string, userId: string, username: string, + tokenVersion: number, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { - const accessToken = this.jwt.sign({ sub: userId, tenantCode, username }); + const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion }); const jti = randomUUID(); await this.prisma.client.refreshToken.create({ data: { diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 031085e..0a9f206 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { IS_PUBLIC_KEY } from './decorators.js'; +import { AuthService } from './auth.service.js'; export interface AuthUser { userId: string; @@ -14,6 +15,7 @@ export class JwtAuthGuard implements CanActivate { constructor( private readonly jwt: JwtService, private readonly reflector: Reflector, + private readonly auth: AuthService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -30,10 +32,13 @@ 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 }>(token); + const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token); + const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0); + if (!active) throw new UnauthorizedException('账号已停用或登录已失效'); request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser; return true; - } catch { + } catch (e) { + if (e instanceof UnauthorizedException) throw e; throw new UnauthorizedException('token 无效或已过期'); } } diff --git a/src/common/base-crud.service.ts b/src/common/base-crud.service.ts index 502389c..29636be 100644 --- a/src/common/base-crud.service.ts +++ b/src/common/base-crud.service.ts @@ -1,6 +1,21 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { getRequestContext } from './context/request-context.js'; +/** + * 软删时需「改名回收」唯一键的模型映射:modelName → 唯一字段及其列长。 + * 目的:软删只置 deletedAt 不释放 @@unique,重建同名会 P2002;删除时把唯一字段改名 + * 成「原值(截断) + #id」释放唯一键,展示名字段(name/label)保持不变。 + */ +const RECYCLE_FIELDS: Record = { + user: { field: 'username', maxLen: 64 }, + role: { field: 'code', maxLen: 64 }, + resource: { field: 'code', maxLen: 128 }, + org: { field: 'code', maxLen: 64 }, + dictType: { field: 'code', maxLen: 64 }, + dictItem: { field: 'value', maxLen: 64 }, + config: { field: 'key', maxLen: 128 }, +}; + /** * 通用 CRUD 基类:封装分页/创建/更新/软删。 * 创建时自动注入 tenantCode(Tenant 模型除外);软删由子类按需调用 softRemove。 @@ -47,6 +62,18 @@ export class BaseCrudService { } async softRemove(id: string) { - return this.db().update({ where: { id }, data: { deletedAt: new Date() } }); + const recycle = RECYCLE_FIELDS[this.modelName]; + if (!recycle) { + return this.db().update({ where: { id }, data: { deletedAt: new Date() } }); + } + // 先读原值,改名回收唯一键,再软删 + const record = await this.findById(id); + const original = record?.[recycle.field]; + if (original == null) { + return this.db().update({ where: { id }, data: { deletedAt: new Date() } }); + } + const suffix = `#${id}`; + const recycled = `${String(original).slice(0, recycle.maxLen - suffix.length)}${suffix}`; + return this.db().update({ where: { id }, data: { [recycle.field]: recycled, deletedAt: new Date() } }); } } diff --git a/src/common/constants.ts b/src/common/constants.ts new file mode 100644 index 0000000..3fbbe20 --- /dev/null +++ b/src/common/constants.ts @@ -0,0 +1,24 @@ +/** + * 平台级常量:平台租户与保留角色码的单一事实源。 + */ + +/** 平台租户(投云科技,根租户):唯一可维护租户表的租户。 */ +export const PLATFORM_TENANT_CODE = 'touyun'; + +/** 平台超管角色码(保留):isSuper 全绕过 + 唯一拥有 tenant:* 权限。 */ +export const PLATFORM_ROLE_CODE = 'super_admin'; + +/** 业务租户顶层管理员角色码(保留):本租户内除 tenant:* 外全权限。 */ +export const TENANT_ADMIN_ROLE_CODE = 'tenant_super_admin'; + +/** 保留角色码:租户不可自行创建。 */ +export const RESERVED_ROLE_CODES: readonly string[] = [PLATFORM_ROLE_CODE, TENANT_ADMIN_ROLE_CODE]; + +/** 平台级权限码前缀(仅平台租户可授予)。 */ +export const PLATFORM_PERMISSION_PREFIX = 'tenant:'; + +/** 权限集缓存 key(Redis,供守卫/授权读取)。 */ +export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`; + +/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */ +export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`; diff --git a/src/common/data-scope.ts b/src/common/data-scope.ts index 58cfd56..9ab2a16 100644 --- a/src/common/data-scope.ts +++ b/src/common/data-scope.ts @@ -32,6 +32,8 @@ export async function resolveVisibleOrgs(opts: { case 'all': return []; case 'self': + // 仅本人:不走组织过滤,由调用方按 userId 过滤(返回哨兵避免误放大为全量) + return ['__none__']; case 'dept': return orgCode ? [orgCode] : ['__none__']; case 'dept_and_child': @@ -47,14 +49,22 @@ export async function resolveVisibleOrgs(opts: { } } -/** 收集某部门及其全部后代部门的 code(含自身)。 */ +/** 收集某部门及其全部后代部门的 code(含自身),带 visited 防环。 */ async function collectDescendants(prisma: PrismaClient, tenantCode: string, rootCode: string): Promise { const result: string[] = []; + const visited = new Set(); let frontier = [rootCode]; while (frontier.length > 0) { - result.push(...frontier); + const next: string[] = []; + for (const code of frontier) { + if (visited.has(code)) continue; + visited.add(code); + result.push(code); + next.push(code); + } + if (next.length === 0) break; const children = await prisma.org.findMany({ - where: { tenantCode, parentCode: { in: frontier }, deletedAt: null }, + where: { tenantCode, parentCode: { in: next }, deletedAt: null }, select: { code: true }, }); frontier = children.map((c) => c.code); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 7fa8deb..7b927e1 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -1,4 +1,5 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import type { Response } from 'express'; import { InjectPinoLogger, PinoLogger } from 'nestjs-pino'; @@ -13,14 +14,32 @@ export class AllExceptionsFilter implements ExceptionFilter { const ctx = host.switchToHttp(); const response = ctx.getResponse(); - const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - const body = exception instanceof HttpException ? exception.getResponse() : null; - const message = - typeof body === 'string' - ? body - : Array.isArray((body as { message?: unknown })?.message) - ? ((body as { message: string[] }).message.join('; ')) - : ((body as { message?: string })?.message ?? (exception as Error).message ?? 'Internal server error'); + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message: string; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const body = exception.getResponse(); + message = + typeof body === 'string' + ? body + : Array.isArray((body as { message?: unknown })?.message) + ? ((body as { message: string[] }).message.join('; ')) + : ((body as { message?: string })?.message ?? exception.message); + } else if (exception instanceof Prisma.PrismaClientKnownRequestError) { + if (exception.code === 'P2002') { + status = HttpStatus.CONFLICT; + message = '数据已存在(唯一约束冲突),请勿重复创建'; + } else if (exception.code === 'P2025') { + status = HttpStatus.NOT_FOUND; + message = '记录不存在或已被删除'; + } else { + message = '数据库操作失败'; + } + } else { + // 未知异常:不向客户端泄漏内部错误细节 + message = '系统内部错误'; + } if (status >= 500) { this.logger.error({ err: exception }, message); diff --git a/src/common/mask.ts b/src/common/mask.ts index 8e737fd..c598ba4 100644 --- a/src/common/mask.ts +++ b/src/common/mask.ts @@ -9,6 +9,9 @@ const SENSITIVE_KEYS = new Set([ 'token', 'secret', 'authorization', + 'phone', + 'mobile', + 'email', ]); export function maskSensitive(value: unknown): unknown { diff --git a/src/common/page.dto.ts b/src/common/page.dto.ts index 0b6fdac..231efb0 100644 --- a/src/common/page.dto.ts +++ b/src/common/page.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsOptional, Min } from 'class-validator'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; export class PageQuery { @ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 }) @@ -15,6 +15,7 @@ export class PageQuery { @Type(() => Number) @IsInt() @Min(1) + @Max(200) pageSize = 20; } diff --git a/src/dicts/dto.ts b/src/dicts/dto.ts index 6ae3304..5a8048e 100644 --- a/src/dicts/dto.ts +++ b/src/dicts/dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { CommonStatus } from '../common/enums.js'; /** 标签色(对应前端 TagType)。 */ const COLOR_TYPES = ['primary', 'success', 'warning', 'danger', 'info'] as const; @@ -29,7 +30,7 @@ export class UpdateDictTypeDto { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; @ApiPropertyOptional({ description: '备注' }) @@ -83,7 +84,7 @@ export class UpdateDictItemDto { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; @ApiPropertyOptional({ description: '标签色:primary/success/warning/danger/info' }) diff --git a/src/orgs/dto.ts b/src/orgs/dto.ts index 65d1251..e31d699 100644 --- a/src/orgs/dto.ts +++ b/src/orgs/dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { CommonStatus } from '../common/enums.js'; export class CreateOrgDto { @ApiProperty({ description: '组织 code(租户内唯一)' }) @@ -51,6 +52,6 @@ export class UpdateOrgDto { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; } diff --git a/src/orgs/orgs.service.ts b/src/orgs/orgs.service.ts index 70e2e93..a4029b9 100644 --- a/src/orgs/orgs.service.ts +++ b/src/orgs/orgs.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; import { CreateOrgDto, UpdateOrgDto } from './dto.js'; @@ -9,11 +9,35 @@ export class OrgsService extends BaseCrudService { super(prisma, 'org'); } - createOrg(dto: CreateOrgDto) { + async createOrg(dto: CreateOrgDto) { + await this.assertValidParent(dto.parentCode); return this.create({ ...dto }); } - updateOrg(id: string, dto: UpdateOrgDto) { + async updateOrg(id: string, dto: UpdateOrgDto) { + if (dto.parentCode !== undefined) { + const current = await this.findById(id); + await this.assertValidParent(dto.parentCode, current?.code); + } return this.update(id, { ...dto }); } + + /** 校验父组织存在,且不会构成环(父不能是自身或自身后代)。 */ + private async assertValidParent(parentCode: string | null | undefined, selfCode?: string): Promise { + if (!parentCode) return; // 根节点 + const parent = await this.db().findFirst({ where: { code: parentCode } }); + if (!parent) throw new BadRequestException('父组织不存在'); + if (!selfCode) return; + let cur: string | null = parentCode; + const visited = new Set(); + while (cur && !visited.has(cur)) { + if (cur === selfCode) throw new BadRequestException('不能将组织挂到自身或其子组织下'); + visited.add(cur); + const node: { parentCode: string | null } | null = await this.db().findFirst({ + where: { code: cur }, + select: { parentCode: true }, + }); + cur = node?.parentCode ?? null; + } + } } diff --git a/src/resources/dto.ts b/src/resources/dto.ts index 816f48b..c8a1630 100644 --- a/src/resources/dto.ts +++ b/src/resources/dto.ts @@ -1,9 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { CommonStatus, ResourceType } from '../common/enums.js'; export class CreateResourceDto { @ApiProperty({ description: '类型:menu/button/api' }) - @IsString() + @IsIn(Object.values(ResourceType)) @IsNotEmpty() type!: string; @@ -96,6 +97,6 @@ export class UpdateResourceDto { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; } diff --git a/src/roles/dto.ts b/src/roles/dto.ts index e9470c6..8107610 100644 --- a/src/roles/dto.ts +++ b/src/roles/dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { PageQuery } from '../common/page.dto.js'; +import { CommonStatus, DataScope } from '../common/enums.js'; export class CreateRoleDto { @ApiProperty({ description: '角色编码' }) @@ -15,7 +16,7 @@ export class CreateRoleDto { @ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom', default: 'all' }) @IsOptional() - @IsString() + @IsIn(Object.values(DataScope)) dataScope?: string; @ApiPropertyOptional({ description: '备注' }) @@ -32,12 +33,12 @@ export class UpdateRoleDto { @ApiPropertyOptional({ description: '数据权限:all/dept/dept_and_child/self/custom' }) @IsOptional() - @IsString() + @IsIn(Object.values(DataScope)) dataScope?: string; @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; @ApiPropertyOptional({ description: '备注' }) @@ -66,6 +67,6 @@ export class ListRoleQuery extends PageQuery { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; } diff --git a/src/roles/roles.controller.ts b/src/roles/roles.controller.ts index bc7eadb..b10b3ce 100644 --- a/src/roles/roles.controller.ts +++ b/src/roles/roles.controller.ts @@ -38,7 +38,7 @@ export class RolesController { @RequirePermissions(Permissions.role.delete) @AuditLog('role', 'delete') remove(@Param('id') id: string) { - return this.service.softRemove(id); + return this.service.removeRole(id); } @Get(':id/resources') diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index fdbcde1..8a0d625 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; +import { PLATFORM_PERMISSION_PREFIX, PLATFORM_TENANT_CODE, RESERVED_ROLE_CODES } from '../common/constants.js'; import { AssignResourcesDto, CreateRoleDto, ListRoleQuery, UpdateRoleDto } from './dto.js'; @Injectable() @@ -15,6 +16,9 @@ export class RolesService extends BaseCrudService { } createRole(dto: CreateRoleDto) { + if (RESERVED_ROLE_CODES.includes(dto.code)) { + throw new BadRequestException(`角色编码 ${dto.code} 为系统保留,不可创建`); + } return this.create({ ...dto }); } @@ -30,6 +34,23 @@ export class RolesService extends BaseCrudService { return this.paginate(q.page, q.pageSize, where); } + /** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */ + async removeRole(id: string) { + const ctx = getRequestContext()!; + const role = await this.findById(id); + if (!role) throw new BadRequestException('角色不存在'); + if (RESERVED_ROLE_CODES.includes(role.code)) { + throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`); + } + const roleCode = role.code; + await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + await this.softRemove(id); + await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); + return null; + } + async assignedResources(roleCode: string) { const ctx = getRequestContext()!; const rows = await this.prisma.client.roleResource.findMany({ @@ -41,12 +62,22 @@ export class RolesService extends BaseCrudService { async assignResources(roleCode: string, dto: AssignResourcesDto) { const ctx = getRequestContext()!; - await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); - if (dto.resourceCodes.length > 0) { - await this.prisma.client.roleResource.createMany({ - data: dto.resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })), - }); - } + // 平台级权限(tenant:*)仅平台租户可授予,非平台租户一律剥离 + const resourceCodes = + ctx.tenantCode === PLATFORM_TENANT_CODE + ? dto.resourceCodes + : dto.resourceCodes.filter((code) => !code.startsWith(PLATFORM_PERMISSION_PREFIX)); + const role = await this.db().findFirst({ where: { code: roleCode } }); + if (!role) throw new BadRequestException('角色不存在'); + // 事务化:删除 + 重建授权,避免中途失败导致角色授权被清空 + await this.prisma.client.$transaction(async (tx) => { + await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + if (resourceCodes.length > 0) { + await tx.roleResource.createMany({ + data: resourceCodes.map((resourceCode) => ({ tenantCode: ctx.tenantCode, roleCode, resourceCode })), + }); + } + }); // 授权变更:删除该租户全部用户权限缓存,下一次请求守卫重新聚合并回填 await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); return null; diff --git a/src/tenants/dto.ts b/src/tenants/dto.ts index 49941d8..e9752ee 100644 --- a/src/tenants/dto.ts +++ b/src/tenants/dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { PageQuery } from '../common/page.dto.js'; +import { CommonStatus } from '../common/enums.js'; export class CreateTenantDto { @ApiProperty({ description: '租户编码' }) @@ -15,7 +16,7 @@ export class CreateTenantDto { @ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; @ApiPropertyOptional({ description: '备注' }) @@ -32,7 +33,7 @@ export class UpdateTenantDto { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; @ApiPropertyOptional({ description: '备注' }) @@ -54,6 +55,6 @@ export class ListTenantQuery extends PageQuery { @ApiPropertyOptional({ description: '状态:enabled/disabled' }) @IsOptional() - @IsString() + @IsIn(Object.values(CommonStatus)) status?: string; } diff --git a/src/users/dto.ts b/src/users/dto.ts index 2f1b664..faa00fc 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { PageQuery } from '../common/page.dto.js'; +import { UserStatus } from '../common/enums.js'; export class CreateUserDto { @ApiProperty({ description: '用户名' }) @@ -67,7 +68,7 @@ export class UpdateUserDto { @ApiPropertyOptional({ description: '状态:enabled/disabled/locked' }) @IsOptional() - @IsString() + @IsIn(Object.values(UserStatus)) status?: string; } @@ -96,6 +97,6 @@ export class ListUserQuery extends PageQuery { @ApiPropertyOptional({ description: '状态:enabled/disabled/locked' }) @IsOptional() - @IsString() + @IsIn(Object.values(UserStatus)) status?: string; } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index fdde430..f265e37 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,13 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; +import { permCacheKey, userStateCacheKey } from '../common/constants.js'; import { CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; @Injectable() export class UsersService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + private readonly redis: RedisService, + ) { super(prisma, 'user'); } @@ -25,8 +30,10 @@ export class UsersService extends BaseCrudService { if (q.nickname) where.nickname = { contains: q.nickname }; if (q.phone) where.phone = { contains: q.phone }; if (q.status) where.status = q.status; - // 数据权限:orgCodes 为空表示「全部可见」,否则限制在可见部门集合 - if (ctx && ctx.orgCodes.length > 0) { + // 数据权限:仅本人按 userId 过滤;否则限制在可见部门集合(空数组=全部可见) + if (ctx && ctx.dataScope === 'self') { + where.id = ctx.userId; + } else if (ctx && ctx.orgCodes.length > 0) { where.orgCode = { in: ctx.orgCodes }; } const result = await this.paginate(q.page, q.pageSize, where); @@ -35,26 +42,55 @@ export class UsersService extends BaseCrudService { } async findById(id: string) { + await this.assertCanView(id); const user = await super.findById(id); return user ? this.omitPasswordHash(user) : user; } + /** 详情查看的数据权限校验:越界直接 403。 */ + private async assertCanView(userId: string): Promise { + const ctx = getRequestContext(); + if (!ctx || ctx.dataScope === 'all') return; + if (ctx.dataScope === 'self') { + if (userId !== ctx.userId) throw new ForbiddenException('无权查看该用户'); + return; + } + const scopes = ctx.orgCodes.length ? ctx.orgCodes : ['__none__']; + const target = await this.db().findFirst({ where: { id: userId, orgCode: { in: scopes } } }); + if (!target) throw new ForbiddenException('无权查看该用户'); + } + async updateUser(id: string, dto: UpdateUserDto) { const updated = await this.update(id, { ...dto }); + if (dto.status && dto.status !== 'enabled') { + await this.invalidateSessions(id); + } return this.omitPasswordHash(updated); } async resetPassword(id: string, dto: ResetPasswordDto) { const passwordHash = await bcrypt.hash(dto.password, 10); const updated = await this.update(id, { passwordHash }); + await this.invalidateSessions(id); return this.omitPasswordHash(updated); } async softRemove(id: string) { const removed = await super.softRemove(id); + await this.invalidateSessions(id); return this.omitPasswordHash(removed); } + /** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */ + private async invalidateSessions(userId: string): Promise { + const ctx = getRequestContext(); + if (!ctx) return; + await this.db().updateMany({ where: { id: userId }, data: { tokenVersion: { increment: 1 } } }); + await this.prisma.client.refreshToken.deleteMany({ where: { tenantCode: ctx.tenantCode, userId } }); + await this.redis.del(permCacheKey(ctx.tenantCode, userId)); + await this.redis.del(userStateCacheKey(ctx.tenantCode, userId)); + } + /** 响应边界剥掉 passwordHash,避免泄漏(登录内部校验直接查库,不受影响)。 */ private omitPasswordHash(row: T): Omit { const { passwordHash: _passwordHash, ...rest } = row;