From 200cedeb59447c2f4199a4700a065785d79acf7e Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 17:22:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC=E4=BA=8C=E8=BD=AE=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 资源变更清权限缓存(H4 残留) - 菜单聚合过滤已删角色(与 resolveAuth 一致) - removeRole 事务化;removeTenant 清 Redis(userState/perm) - createTenant 事务化 + 未提供密码时生成随机密码 - 登录失败锁定加 IP 维度;register 禁止平台租户 - refresh token 落 ip/userAgent;e2e 用独立账号避免锁死 - .env.example 补 CORS_ORIGIN/NODE_ENV、脱敏默认密码 Co-Authored-By: Claude Opus 4.8 --- .env.example | 10 ++++-- src/auth/auth.service.ts | 53 ++++++++++++++++++++---------- src/resources/resources.service.ts | 31 ++++++++++++++--- src/roles/roles.service.ts | 17 +++++++--- src/tenants/tenants.service.ts | 49 ++++++++++++++++----------- test/app.e2e-spec.ts | 2 +- 6 files changed, 113 insertions(+), 49 deletions(-) diff --git a/.env.example b/.env.example index 57ee239..17916f1 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,18 @@ # 复制为 .env 并按需修改 # 数据库(MySQL 8) -DATABASE_URL="mysql://root:Mysql.123@127.0.0.1:3306/sysmgr" +DATABASE_URL="mysql://root:请替换为数据库密码@127.0.0.1:3306/sysmgr" # 服务端口 PORT=3000 -# JWT(生产务必替换 secret) +# 运行环境(production 会关闭 Swagger 并强校验 JWT_SECRET) +NODE_ENV="development" + +# CORS 允许来源(逗号分隔;生产改为实际前端域名) +CORS_ORIGIN="http://localhost:5173" + +# JWT(生产务必替换 secret,至少 32 位) JWT_SECRET="dev-secret-change-me" JWT_ACCESS_TTL="7200" JWT_REFRESH_TTL="604800" diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 694e5fe..e331c9d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,7 +10,7 @@ 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'; +import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, 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); @@ -88,14 +88,14 @@ export class AuthService { | { accessToken: string; refreshToken: string; expiresIn: number } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } > { - await this.assertNotLocked(dto.username); + await this.assertNotLocked(ip, dto.username); if (dto.tenantCode) { try { const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent); - await this.clearLoginFail(dto.username); + await this.clearLoginFail(ip, dto.username); return result; } catch (e) { - if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username); + if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username); throw e; } } @@ -114,33 +114,37 @@ export class AuthService { } } if (matched.length === 0) { - await this.recordLoginFail(dto.username); + await this.recordLoginFail(ip, dto.username); await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent); throw new UnauthorizedException('账号或密码错误'); } - await this.clearLoginFail(dto.username); + await this.clearLoginFail(ip, dto.username); if (matched.length === 1) { return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent); } return { needSelectTenant: true, tenants: matched }; } - /** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */ - private async assertNotLocked(username: string): Promise { - const count = (await this.redis.getJson(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0; + /** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期(按 IP + 用户名,防恶意锁他人账号)。 */ + private async assertNotLocked(ip: string | undefined, username: string): Promise { + const count = (await this.redis.getJson(this.lockKey(ip, username))) ?? 0; if (count >= MAX_LOGIN_FAILS) { throw new UnauthorizedException('登录失败次数过多,请稍后再试'); } } - private async recordLoginFail(username: string): Promise { - const key = `${LOGIN_FAIL_PREFIX}${username}`; + private async recordLoginFail(ip: string | undefined, username: string): Promise { + const key = this.lockKey(ip, username); const count = ((await this.redis.getJson(key)) ?? 0) + 1; await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS); } - private async clearLoginFail(username: string): Promise { - await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`); + private async clearLoginFail(ip: string | undefined, username: string): Promise { + await this.redis.del(this.lockKey(ip, username)); + } + + private lockKey(ip: string | undefined, username: string): string { + return `${LOGIN_FAIL_PREFIX}${ip ?? 'unknown'}:${username}`; } /** 指定租户登录(单租户逻辑)。 */ @@ -184,11 +188,14 @@ export class AuthService { ip: ip ?? null, userAgent: userAgent ?? null, }); - return this.issue(tenantCode, user.id, user.username, user.tokenVersion); + return this.issue(tenantCode, user.id, user.username, user.tokenVersion, ip, userAgent); } - /** 注册:在指定租户下创建普通用户(无角色,状态启用)。 */ + /** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */ async register(dto: RegisterDto): Promise<{ id: string; username: string }> { + if (dto.tenantCode === PLATFORM_TENANT_CODE) { + throw new BadRequestException('平台租户不允许注册'); + } const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } }); if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用'); const exists = await this.prisma.client.user.findFirst({ @@ -285,7 +292,15 @@ export class AuthService { select: { roleCode: true }, }); const roleCodes = roleRows.map((r) => r.roleCode); - const isSuper = roleCodes.includes(SUPER_ROLE); + // 过滤已删除角色(与 resolveAuth 保持一致) + 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 rows = isSuper ? await this.prisma.client.resource.findMany({ @@ -293,7 +308,7 @@ export class AuthService { orderBy: { sort: 'asc' }, select: MENU_SELECT, }) - : await this.menusForRoles(tenantCode, roleCodes); + : await this.menusForRoles(tenantCode, activeRoleCodes); return buildMenuTree(rows as MenuRow[]); } @@ -459,6 +474,8 @@ export class AuthService { userId: string, username: string, tokenVersion: number, + ip?: string, + userAgent?: string, ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion }); const jti = randomUUID(); @@ -468,6 +485,8 @@ export class AuthService { userId, jti, tokenHash: createHash('sha256').update(jti).digest('hex'), + ip: ip ?? null, + userAgent: userAgent ?? null, expireAt: new Date(Date.now() + REFRESH_TTL * 1000), }, }); diff --git a/src/resources/resources.service.ts b/src/resources/resources.service.ts index fc68d3e..7ef11ce 100644 --- a/src/resources/resources.service.ts +++ b/src/resources/resources.service.ts @@ -1,19 +1,40 @@ import { 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 { CreateResourceDto, UpdateResourceDto } from './dto.js'; @Injectable() export class ResourcesService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + private readonly redis: RedisService, + ) { super(prisma, 'resource'); } - createResource(dto: CreateResourceDto) { - return this.create({ ...dto }); + async createResource(dto: CreateResourceDto) { + const created = await this.create({ ...dto }); + await this.clearPermCache(); + return created; } - updateResource(id: string, dto: UpdateResourceDto) { - return this.update(id, { ...dto }); + async updateResource(id: string, dto: UpdateResourceDto) { + const updated = await this.update(id, { ...dto }); + await this.clearPermCache(); + return updated; + } + + async softRemove(id: string) { + const removed = await super.softRemove(id); + await this.clearPermCache(); + return removed; + } + + /** 资源变更(含停用/删除)影响权限码聚合,清该租户全部权限缓存。 */ + private async clearPermCache(): Promise { + const ctx = getRequestContext(); + if (ctx) await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); } } diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index 8a0d625..d248ece 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -34,7 +34,7 @@ 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); @@ -43,10 +43,17 @@ export class RolesService extends BaseCrudService { 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.prisma.raw.$transaction(async (tx) => { + await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + await tx.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); + // 软删 + 回收 code 唯一键 + const suffix = `#${id}`; + await tx.role.update({ + where: { id }, + data: { code: `${roleCode.slice(0, 64 - suffix.length)}${suffix}`, deletedAt: new Date() }, + }); + }); await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`); return null; } diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index c6b00ed..a180b64 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -1,35 +1,43 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { PrismaClient } from '@prisma/client'; import { BaseCrudService } from '../common/base-crud.service.js'; import { PrismaService } from '../prisma/prisma.service.js'; +import { RedisService } from '../common/redis/redis.service.js'; import { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js'; import { initTenant } from '../common/tenant-template.js'; import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js'; @Injectable() export class TenantsService extends BaseCrudService { - constructor(prisma: PrismaService) { + constructor( + prisma: PrismaService, + private readonly redis: RedisService, + ) { super(prisma, 'tenant'); } - /** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */ + /** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */ async createTenant(dto: CreateTenantDto) { - const tenant = await this.create({ - code: dto.code, - name: dto.name, - status: dto.status ?? 'enabled', - remark: dto.remark, + const code = dto.code; + const adminPassword = dto.adminPassword ?? randomUUID().slice(0, 12); + await this.prisma.raw.$transaction(async (tx) => { + await tx.tenant.create({ + data: { code, name: dto.name, status: dto.status ?? 'enabled', remark: dto.remark }, + }); + await initTenant({ + prisma: tx as unknown as PrismaClient, + tenantCode: code, + tenantName: dto.name, + adminRoleCode: TENANT_ADMIN_ROLE_CODE, + adminRoleName: '租户管理员', + adminUsername: dto.adminUsername ?? 'admin', + adminPassword, + includePlatformPerms: false, + }); }); - await initTenant({ - prisma: this.prisma.raw, - tenantCode: dto.code, - tenantName: dto.name, - adminRoleCode: TENANT_ADMIN_ROLE_CODE, - adminRoleName: '租户管理员', - adminUsername: dto.adminUsername ?? 'admin', - adminPassword: dto.adminPassword ?? 'Admin@123456', - includePlatformPerms: false, - }); - return tenant; + const tenant = await this.db().findUnique({ where: { code } }); + return dto.adminPassword ? tenant : { ...tenant, adminPassword }; } listTenants(q: ListTenantQuery) { @@ -46,7 +54,7 @@ export class TenantsService extends BaseCrudService { return this.update(id, { ...dto }); } - /** 删除租户并级联清理其全部业务数据。 */ + /** 删除租户并级联清理全部业务数据与 Redis 缓存。 */ async removeTenant(id: string) { const existing = await this.findById(id); if (!existing) throw new NotFoundException('租户不存在'); @@ -68,6 +76,9 @@ export class TenantsService extends BaseCrudService { db.user.deleteMany({ where: { tenantCode: code } }), db.tenant.delete({ where: { id } }), ]); + // 清该租户的权限缓存与用户状态缓存(旧 token 立即失效) + await this.redis.delByPrefix(`perm:${code}:`); + await this.redis.delByPrefix(`user:${code}:`); return null; } } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 9dcf563..0f11aed 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -34,7 +34,7 @@ describe('Auth (e2e)', () => { it('密码错误返回 401', async () => { const res = await request(app.getHttpServer()) .post('/api/v1/auth/login') - .send({ username: 'touyun', password: 'wrong-password' }) + .send({ username: 'e2e_wrong_user', password: 'wrong-password' }) .expect(401); expect(res.body.code).toBe(40100); });