fix: 第二轮残留问题

- 资源变更清权限缓存(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 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 17:22:10 +08:00
parent 9a9435e665
commit 200cedeb59
6 changed files with 113 additions and 49 deletions

View File

@ -1,12 +1,18 @@
# 复制为 .env 并按需修改 # 复制为 .env 并按需修改
# 数据库(MySQL 8) # 数据库(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 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_SECRET="dev-secret-change-me"
JWT_ACCESS_TTL="7200" JWT_ACCESS_TTL="7200"
JWT_REFRESH_TTL="604800" JWT_REFRESH_TTL="604800"

View File

@ -10,7 +10,7 @@ import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js'; import type { RequestContext } from '../common/context/request-context.js';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { LoginLogsService } from '../login-logs/login-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 REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200); const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
@ -88,14 +88,14 @@ export class AuthService {
| { accessToken: string; refreshToken: string; expiresIn: number } | { accessToken: string; refreshToken: string; expiresIn: number }
| { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] } | { needSelectTenant: true; tenants: { tenantCode: string; tenantName: string }[] }
> { > {
await this.assertNotLocked(dto.username); await this.assertNotLocked(ip, dto.username);
if (dto.tenantCode) { if (dto.tenantCode) {
try { try {
const result = await this.loginTenant(dto.tenantCode, dto.username, dto.password, ip, userAgent); 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; return result;
} catch (e) { } catch (e) {
if (e instanceof UnauthorizedException) await this.recordLoginFail(dto.username); if (e instanceof UnauthorizedException) await this.recordLoginFail(ip, dto.username);
throw e; throw e;
} }
} }
@ -114,33 +114,37 @@ export class AuthService {
} }
} }
if (matched.length === 0) { if (matched.length === 0) {
await this.recordLoginFail(dto.username); await this.recordLoginFail(ip, dto.username);
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent); await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误'); throw new UnauthorizedException('账号或密码错误');
} }
await this.clearLoginFail(dto.username); await this.clearLoginFail(ip, dto.username);
if (matched.length === 1) { if (matched.length === 1) {
return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent); return this.loginTenant(matched[0].tenantCode, dto.username, dto.password, ip, userAgent);
} }
return { needSelectTenant: true, tenants: matched }; return { needSelectTenant: true, tenants: matched };
} }
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期。 */ /** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期(按 IP + 用户名,防恶意锁他人账号)。 */
private async assertNotLocked(username: string): Promise<void> { private async assertNotLocked(ip: string | undefined, username: string): Promise<void> {
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0; const count = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
if (count >= MAX_LOGIN_FAILS) { if (count >= MAX_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试'); throw new UnauthorizedException('登录失败次数过多,请稍后再试');
} }
} }
private async recordLoginFail(username: string): Promise<void> { private async recordLoginFail(ip: string | undefined, username: string): Promise<void> {
const key = `${LOGIN_FAIL_PREFIX}${username}`; const key = this.lockKey(ip, username);
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1; const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS); await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
} }
private async clearLoginFail(username: string): Promise<void> { private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`); 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, ip: ip ?? null,
userAgent: userAgent ?? 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 }> { 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 } }); const tenant = await this.prisma.client.tenant.findUnique({ where: { code: dto.tenantCode } });
if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用'); if (!tenant || tenant.status !== 'enabled') throw new BadRequestException('租户不存在或已停用');
const exists = await this.prisma.client.user.findFirst({ const exists = await this.prisma.client.user.findFirst({
@ -285,7 +292,15 @@ export class AuthService {
select: { roleCode: true }, select: { roleCode: true },
}); });
const roleCodes = roleRows.map((r) => r.roleCode); 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 const rows = isSuper
? await this.prisma.client.resource.findMany({ ? await this.prisma.client.resource.findMany({
@ -293,7 +308,7 @@ export class AuthService {
orderBy: { sort: 'asc' }, orderBy: { sort: 'asc' },
select: MENU_SELECT, select: MENU_SELECT,
}) })
: await this.menusForRoles(tenantCode, roleCodes); : await this.menusForRoles(tenantCode, activeRoleCodes);
return buildMenuTree(rows as MenuRow[]); return buildMenuTree(rows as MenuRow[]);
} }
@ -459,6 +474,8 @@ export class AuthService {
userId: string, userId: string,
username: string, username: string,
tokenVersion: number, tokenVersion: number,
ip?: string,
userAgent?: string,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> { ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion }); const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
const jti = randomUUID(); const jti = randomUUID();
@ -468,6 +485,8 @@ export class AuthService {
userId, userId,
jti, jti,
tokenHash: createHash('sha256').update(jti).digest('hex'), tokenHash: createHash('sha256').update(jti).digest('hex'),
ip: ip ?? null,
userAgent: userAgent ?? null,
expireAt: new Date(Date.now() + REFRESH_TTL * 1000), expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
}, },
}); });

View File

@ -1,19 +1,40 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js'; import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.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'; import { CreateResourceDto, UpdateResourceDto } from './dto.js';
@Injectable() @Injectable()
export class ResourcesService extends BaseCrudService { export class ResourcesService extends BaseCrudService {
constructor(prisma: PrismaService) { constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'resource'); super(prisma, 'resource');
} }
createResource(dto: CreateResourceDto) { async createResource(dto: CreateResourceDto) {
return this.create({ ...dto }); const created = await this.create({ ...dto });
await this.clearPermCache();
return created;
} }
updateResource(id: string, dto: UpdateResourceDto) { async updateResource(id: string, dto: UpdateResourceDto) {
return this.update(id, { ...dto }); 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<void> {
const ctx = getRequestContext();
if (ctx) await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
} }
} }

View File

@ -34,7 +34,7 @@ export class RolesService extends BaseCrudService {
return this.paginate(q.page, q.pageSize, where); return this.paginate(q.page, q.pageSize, where);
} }
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */ /** 删除角色:级联清理关联并清权限缓存(保留角色不可删),事务化避免半删。 */
async removeRole(id: string) { async removeRole(id: string) {
const ctx = getRequestContext()!; const ctx = getRequestContext()!;
const role = await this.findById(id); const role = await this.findById(id);
@ -43,10 +43,17 @@ export class RolesService extends BaseCrudService {
throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`); throw new BadRequestException(`角色 ${role.code} 为系统保留角色,不可删除`);
} }
const roleCode = role.code; const roleCode = role.code;
await this.prisma.client.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await this.prisma.raw.$transaction(async (tx) => {
await this.prisma.client.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await tx.roleResource.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.prisma.client.userRole.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
await this.softRemove(id); 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}:`); await this.redis.delByPrefix(`perm:${ctx.tenantCode}:`);
return null; return null;
} }

View File

@ -1,35 +1,43 @@
import { Injectable, NotFoundException } from '@nestjs/common'; 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 { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.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 { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js';
import { initTenant } from '../common/tenant-template.js'; import { initTenant } from '../common/tenant-template.js';
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js'; import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
@Injectable() @Injectable()
export class TenantsService extends BaseCrudService { export class TenantsService extends BaseCrudService {
constructor(prisma: PrismaService) { constructor(
prisma: PrismaService,
private readonly redis: RedisService,
) {
super(prisma, 'tenant'); super(prisma, 'tenant');
} }
/** 创建租户并初始化:根组织 + 租户管理员角色 + 管理员账号 + 资源 + 字典 + 授权。 */ /** 创建租户并初始化(事务化);未提供管理员密码时生成随机密码返回。 */
async createTenant(dto: CreateTenantDto) { async createTenant(dto: CreateTenantDto) {
const tenant = await this.create({ const code = dto.code;
code: dto.code, const adminPassword = dto.adminPassword ?? randomUUID().slice(0, 12);
name: dto.name, await this.prisma.raw.$transaction(async (tx) => {
status: dto.status ?? 'enabled', await tx.tenant.create({
remark: dto.remark, data: { code, name: dto.name, status: dto.status ?? 'enabled', remark: dto.remark },
}); });
await initTenant({ await initTenant({
prisma: this.prisma.raw, prisma: tx as unknown as PrismaClient,
tenantCode: dto.code, tenantCode: code,
tenantName: dto.name, tenantName: dto.name,
adminRoleCode: TENANT_ADMIN_ROLE_CODE, adminRoleCode: TENANT_ADMIN_ROLE_CODE,
adminRoleName: '租户管理员', adminRoleName: '租户管理员',
adminUsername: dto.adminUsername ?? 'admin', adminUsername: dto.adminUsername ?? 'admin',
adminPassword: dto.adminPassword ?? 'Admin@123456', adminPassword,
includePlatformPerms: false, includePlatformPerms: false,
}); });
return tenant; });
const tenant = await this.db().findUnique({ where: { code } });
return dto.adminPassword ? tenant : { ...tenant, adminPassword };
} }
listTenants(q: ListTenantQuery) { listTenants(q: ListTenantQuery) {
@ -46,7 +54,7 @@ export class TenantsService extends BaseCrudService {
return this.update(id, { ...dto }); return this.update(id, { ...dto });
} }
/** 删除租户并级联清理其全部业务数据。 */ /** 删除租户并级联清理全部业务数据与 Redis 缓存。 */
async removeTenant(id: string) { async removeTenant(id: string) {
const existing = await this.findById(id); const existing = await this.findById(id);
if (!existing) throw new NotFoundException('租户不存在'); if (!existing) throw new NotFoundException('租户不存在');
@ -68,6 +76,9 @@ export class TenantsService extends BaseCrudService {
db.user.deleteMany({ where: { tenantCode: code } }), db.user.deleteMany({ where: { tenantCode: code } }),
db.tenant.delete({ where: { id } }), db.tenant.delete({ where: { id } }),
]); ]);
// 清该租户的权限缓存与用户状态缓存(旧 token 立即失效)
await this.redis.delByPrefix(`perm:${code}:`);
await this.redis.delByPrefix(`user:${code}:`);
return null; return null;
} }
} }

View File

@ -34,7 +34,7 @@ describe('Auth (e2e)', () => {
it('密码错误返回 401', async () => { it('密码错误返回 401', async () => {
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post('/api/v1/auth/login') .post('/api/v1/auth/login')
.send({ username: 'touyun', password: 'wrong-password' }) .send({ username: 'e2e_wrong_user', password: 'wrong-password' })
.expect(401); .expect(401);
expect(res.body.code).toBe(40100); expect(res.body.code).toBe(40100);
}); });