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 并按需修改
# 数据库(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"

View File

@ -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<void> {
const count = (await this.redis.getJson<number>(`${LOGIN_FAIL_PREFIX}${username}`)) ?? 0;
/** 登录失败锁定:连续失败超阈值后拒绝,直到窗口过期(按 IP + 用户名,防恶意锁他人账号)。 */
private async assertNotLocked(ip: string | undefined, username: string): Promise<void> {
const count = (await this.redis.getJson<number>(this.lockKey(ip, username))) ?? 0;
if (count >= MAX_LOGIN_FAILS) {
throw new UnauthorizedException('登录失败次数过多,请稍后再试');
}
}
private async recordLoginFail(username: string): Promise<void> {
const key = `${LOGIN_FAIL_PREFIX}${username}`;
private async recordLoginFail(ip: string | undefined, username: string): Promise<void> {
const key = this.lockKey(ip, username);
const count = ((await this.redis.getJson<number>(key)) ?? 0) + 1;
await this.redis.setJson(key, count, LOGIN_LOCK_SECONDS);
}
private async clearLoginFail(username: string): Promise<void> {
await this.redis.del(`${LOGIN_FAIL_PREFIX}${username}`);
private async clearLoginFail(ip: string | undefined, username: string): Promise<void> {
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),
},
});

View File

@ -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<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);
}
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删)。 */
/** 删除角色:级联清理关联并清权限缓存(保留角色不可删),事务化避免半删。 */
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;
}

View File

@ -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: this.prisma.raw,
tenantCode: dto.code,
prisma: tx as unknown as PrismaClient,
tenantCode: code,
tenantName: dto.name,
adminRoleCode: TENANT_ADMIN_ROLE_CODE,
adminRoleName: '租户管理员',
adminUsername: dto.adminUsername ?? 'admin',
adminPassword: dto.adminPassword ?? 'Admin@123456',
adminPassword,
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;
}
}

View File

@ -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);
});