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:
parent
9a9435e665
commit
200cedeb59
10
.env.example
10
.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"
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@ -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}:`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user