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 并按需修改
|
# 复制为 .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"
|
||||||
|
|||||||
@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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}:`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
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({
|
const tenant = await this.db().findUnique({ where: { code } });
|
||||||
prisma: this.prisma.raw,
|
return dto.adminPassword ? tenant : { ...tenant, adminPassword };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user