feat(session): 在线会话强踢 + 账号锁定/解锁
- JWT 引入 sid,单会话精确强退(sid 黑名单)与按用户踢全部(tokenVersion) - User 新增 lockedAt/lockReason,登录/刷新/守卫统一拒绝锁定 - sessions 模块:活跃会话列表 + 单会话强退;users 新增 lock/unlock/kick-sessions - 资源注册 + syncTenantResources 存量租户补齐脚本 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
127707ffde
commit
86832ce32d
@ -26,7 +26,8 @@
|
|||||||
"test:cov": "vitest run --coverage",
|
"test:cov": "vitest run --coverage",
|
||||||
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
"test:debug": "vitest --inspect-brk --no-file-parallelism",
|
||||||
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
|
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
|
||||||
"seed": "prisma db seed"
|
"seed": "prisma db seed",
|
||||||
|
"sync:resources": "tsx prisma/sync-resources.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^12.0.1",
|
"@nestjs/common": "^12.0.1",
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `sys_refresh_token` ADD COLUMN `sid` VARCHAR(36) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `sys_user` ADD COLUMN `lock_reason` VARCHAR(255) NULL,
|
||||||
|
ADD COLUMN `locked_at` DATETIME(3) NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `sys_refresh_token_sid_idx` ON `sys_refresh_token`(`sid`);
|
||||||
@ -36,6 +36,8 @@ model User {
|
|||||||
avatar String? @db.VarChar(255)
|
avatar String? @db.VarChar(255)
|
||||||
status String @default("enabled") @db.VarChar(16)
|
status String @default("enabled") @db.VarChar(16)
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
|
lockedAt DateTime? @map("locked_at")
|
||||||
|
lockReason String? @map("lock_reason") @db.VarChar(255)
|
||||||
orgCode String? @map("org_code") @db.VarChar(64)
|
orgCode String? @map("org_code") @db.VarChar(64)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String? @map("created_by") @db.VarChar(36)
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
@ -213,6 +215,7 @@ model RefreshToken {
|
|||||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
userId String @map("user_id") @db.VarChar(36)
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
|
sid String? @db.VarChar(36)
|
||||||
jti String @unique @db.VarChar(64)
|
jti String @unique @db.VarChar(64)
|
||||||
tokenHash String @map("token_hash") @db.VarChar(255)
|
tokenHash String @map("token_hash") @db.VarChar(255)
|
||||||
ip String? @db.VarChar(64)
|
ip String? @db.VarChar(64)
|
||||||
@ -223,6 +226,7 @@ model RefreshToken {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@index([tenantCode, userId])
|
@@index([tenantCode, userId])
|
||||||
|
@@index([sid])
|
||||||
@@map("sys_refresh_token")
|
@@map("sys_refresh_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
prisma/sync-resources.ts
Normal file
30
prisma/sync-resources.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE, TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js';
|
||||||
|
import { syncTenantResources } from '../src/common/tenant-template.js';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/** 为所有存量租户补齐资源/字典/授权(幂等;不触碰用户/密码)。 */
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const tenants = await prisma.tenant.findMany({ select: { code: true } });
|
||||||
|
for (const t of tenants) {
|
||||||
|
const isPlatform = t.code === PLATFORM_TENANT_CODE;
|
||||||
|
await syncTenantResources({
|
||||||
|
prisma,
|
||||||
|
tenantCode: t.code,
|
||||||
|
adminRoleCode: isPlatform ? PLATFORM_ROLE_CODE : TENANT_ADMIN_ROLE_CODE,
|
||||||
|
includePlatformPerms: isPlatform,
|
||||||
|
});
|
||||||
|
console.log(`已同步资源:${t.code}(${isPlatform ? '平台' : '业务'}租户)`);
|
||||||
|
}
|
||||||
|
console.log(`syncTenantResources 完成,共 ${tenants.length} 个租户。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@ -23,6 +23,7 @@ import { DictsModule } from './dicts/dicts.module.js';
|
|||||||
import { ConfigsModule } from './configs/configs.module.js';
|
import { ConfigsModule } from './configs/configs.module.js';
|
||||||
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
|
||||||
import { LoginLogsModule } from './login-logs/login-logs.module.js';
|
import { LoginLogsModule } from './login-logs/login-logs.module.js';
|
||||||
|
import { SessionsModule } from './sessions/sessions.module.js';
|
||||||
import { AppController } from './app.controller.js';
|
import { AppController } from './app.controller.js';
|
||||||
import { AppService } from './app.service.js';
|
import { AppService } from './app.service.js';
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ import { AppService } from './app.service.js';
|
|||||||
ConfigsModule,
|
ConfigsModule,
|
||||||
AuditLogsModule,
|
AuditLogsModule,
|
||||||
LoginLogsModule,
|
LoginLogsModule,
|
||||||
|
SessionsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -10,12 +10,11 @@ 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, PLATFORM_TENANT_CODE, permCacheKey, userStateCacheKey } from '../common/constants.js';
|
import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, 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);
|
||||||
|
|
||||||
const LOGIN_FAIL_PREFIX = 'loginFail:';
|
|
||||||
const MAX_LOGIN_FAILS = 5;
|
const MAX_LOGIN_FAILS = 5;
|
||||||
const MAX_GLOBAL_LOGIN_FAILS = 20;
|
const MAX_GLOBAL_LOGIN_FAILS = 20;
|
||||||
const LOGIN_LOCK_SECONDS = 900;
|
const LOGIN_LOCK_SECONDS = 900;
|
||||||
@ -103,11 +102,11 @@ export class AuthService {
|
|||||||
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
||||||
const users = await this.prisma.client.user.findMany({
|
const users = await this.prisma.client.user.findMany({
|
||||||
where: { username: dto.username, deletedAt: null },
|
where: { username: dto.username, deletedAt: null },
|
||||||
select: { tenantCode: true, passwordHash: true, status: true },
|
select: { tenantCode: true, passwordHash: true, status: true, lockedAt: true },
|
||||||
});
|
});
|
||||||
const matched: { tenantCode: string; tenantName: string }[] = [];
|
const matched: { tenantCode: string; tenantName: string }[] = [];
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
if (u.status !== 'enabled') continue;
|
if (u.status !== 'enabled' || u.lockedAt) continue;
|
||||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
|
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
|
||||||
if (!tenant || tenant.status !== 'enabled') continue;
|
if (!tenant || tenant.status !== 'enabled') continue;
|
||||||
if (await bcrypt.compare(dto.password, u.passwordHash)) {
|
if (await bcrypt.compare(dto.password, u.passwordHash)) {
|
||||||
@ -175,9 +174,13 @@ export class AuthService {
|
|||||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
||||||
throw new UnauthorizedException('账号或密码错误');
|
throw new UnauthorizedException('账号或密码错误');
|
||||||
}
|
}
|
||||||
|
if (user.lockedAt) {
|
||||||
|
await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent);
|
||||||
|
throw new UnauthorizedException('账号已锁定');
|
||||||
|
}
|
||||||
if (user.status !== 'enabled') {
|
if (user.status !== 'enabled') {
|
||||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
|
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
|
||||||
throw new UnauthorizedException('账号已停用或锁定');
|
throw new UnauthorizedException('账号已停用');
|
||||||
}
|
}
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@ -196,7 +199,12 @@ 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, ip, userAgent);
|
return this.issue(tenantCode, user.id, user.username, user.tokenVersion, {
|
||||||
|
sid: randomUUID(),
|
||||||
|
loginAt: new Date(),
|
||||||
|
ip: ip ?? null,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
|
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
|
||||||
@ -252,7 +260,7 @@ export class AuthService {
|
|||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
|
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
|
||||||
});
|
});
|
||||||
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
|
if (!user || user.status !== 'enabled' || user.lockedAt) throw new UnauthorizedException('账号已失效');
|
||||||
|
|
||||||
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
|
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
|
||||||
await this.prisma.client.refreshToken.update({
|
await this.prisma.client.refreshToken.update({
|
||||||
@ -260,7 +268,12 @@ export class AuthService {
|
|||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
});
|
});
|
||||||
await this.writePermissionCache(record.tenantCode, user.id);
|
await this.writePermissionCache(record.tenantCode, user.id);
|
||||||
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
|
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion, {
|
||||||
|
sid: record.sid ?? randomUUID(),
|
||||||
|
loginAt: record.loginAt,
|
||||||
|
ip: record.ip,
|
||||||
|
userAgent: record.userAgent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
|
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
|
||||||
@ -389,20 +402,29 @@ export class AuthService {
|
|||||||
return permCacheKey(tenantCode, userId);
|
return permCacheKey(tenantCode, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
|
/** 校验用户当前是否可用(status=enabled、未锁定且 tokenVersion 与签发时一致)。 */
|
||||||
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
|
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
|
||||||
const key = userStateCacheKey(tenantCode, userId);
|
const key = userStateCacheKey(tenantCode, userId);
|
||||||
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
|
let state = await this.redis.getJson<{ status: string; tokenVersion: number; lockedAt: string | null }>(key);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
const user = await this.prisma.client.user.findFirst({
|
const user = await this.prisma.client.user.findFirst({
|
||||||
where: { tenantCode, id: userId, deletedAt: null },
|
where: { tenantCode, id: userId, deletedAt: null },
|
||||||
select: { status: true, tokenVersion: true },
|
select: { status: true, tokenVersion: true, lockedAt: true },
|
||||||
});
|
});
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
state = { status: user.status, tokenVersion: user.tokenVersion };
|
state = {
|
||||||
|
status: user.status,
|
||||||
|
tokenVersion: user.tokenVersion,
|
||||||
|
lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null,
|
||||||
|
};
|
||||||
await this.redis.setJson(key, state, ACCESS_TTL);
|
await this.redis.setJson(key, state, ACCESS_TTL);
|
||||||
}
|
}
|
||||||
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
|
return state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断会话是否已被强退(sid 黑名单)。 */
|
||||||
|
async isSessionRevoked(sid: string): Promise<boolean> {
|
||||||
|
return (await this.redis.getJson(sessionRevokedKey(sid))) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 聚合权限码与超管标志。 */
|
/** 聚合权限码与超管标志。 */
|
||||||
@ -482,19 +504,20 @@ export class AuthService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
username: string,
|
username: string,
|
||||||
tokenVersion: number,
|
tokenVersion: number,
|
||||||
ip?: string,
|
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
|
||||||
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, sid: session.sid });
|
||||||
const jti = randomUUID();
|
const jti = randomUUID();
|
||||||
await this.prisma.client.refreshToken.create({
|
await this.prisma.client.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
tenantCode,
|
tenantCode,
|
||||||
userId,
|
userId,
|
||||||
|
sid: session.sid,
|
||||||
jti,
|
jti,
|
||||||
tokenHash: createHash('sha256').update(jti).digest('hex'),
|
tokenHash: createHash('sha256').update(jti).digest('hex'),
|
||||||
ip: ip ?? null,
|
ip: session.ip ?? null,
|
||||||
userAgent: userAgent ?? null,
|
userAgent: session.userAgent ?? null,
|
||||||
|
loginAt: session.loginAt,
|
||||||
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
|
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,9 +32,12 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
const token = this.extractToken(request);
|
const token = this.extractToken(request);
|
||||||
if (!token) throw new UnauthorizedException('未登录');
|
if (!token) throw new UnauthorizedException('未登录');
|
||||||
try {
|
try {
|
||||||
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
|
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string }>(token);
|
||||||
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
|
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
|
||||||
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
||||||
|
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
|
||||||
|
throw new UnauthorizedException('会话已失效,请重新登录');
|
||||||
|
}
|
||||||
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@ -20,5 +20,11 @@ export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
|
|||||||
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
||||||
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
|
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
|
||||||
|
|
||||||
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
|
/** 用户状态缓存 key(Redis,status + tokenVersion + lockedAt,供 JwtAuthGuard 每请求校验)。 */
|
||||||
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;
|
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;
|
||||||
|
|
||||||
|
/** 会话吊销黑名单 key(Redis,sid → 强退标记,供 JwtAuthGuard 每请求校验)。 */
|
||||||
|
export const sessionRevokedKey = (sid: string): string => `sessionRevoked:${sid}`;
|
||||||
|
|
||||||
|
/** 登录失败计数前缀(Redis)。 */
|
||||||
|
export const LOGIN_FAIL_PREFIX = 'loginFail:';
|
||||||
|
|||||||
@ -12,7 +12,6 @@ export type CommonStatus = (typeof CommonStatus)[keyof typeof CommonStatus];
|
|||||||
export const UserStatus = {
|
export const UserStatus = {
|
||||||
ENABLED: 'enabled',
|
ENABLED: 'enabled',
|
||||||
DISABLED: 'disabled',
|
DISABLED: 'disabled',
|
||||||
LOCKED: 'locked',
|
|
||||||
} as const;
|
} as const;
|
||||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,9 @@ export const Permissions = {
|
|||||||
delete: 'user:delete',
|
delete: 'user:delete',
|
||||||
resetPwd: 'user:reset-pwd',
|
resetPwd: 'user:reset-pwd',
|
||||||
assignRole: 'user:assign-role',
|
assignRole: 'user:assign-role',
|
||||||
|
kickSessions: 'user:kick-sessions',
|
||||||
|
lock: 'user:lock',
|
||||||
|
unlock: 'user:unlock',
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
list: 'role:list',
|
list: 'role:list',
|
||||||
@ -48,6 +51,7 @@ export const Permissions = {
|
|||||||
update: 'config:update',
|
update: 'config:update',
|
||||||
delete: 'config:delete',
|
delete: 'config:delete',
|
||||||
},
|
},
|
||||||
|
session: { list: 'session:list', kick: 'session:kick' },
|
||||||
audit: { list: 'audit:list' },
|
audit: { list: 'audit:list' },
|
||||||
loginLog: { list: 'login-log:list' },
|
loginLog: { list: 'login-log:list' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy {
|
|||||||
|
|
||||||
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
|
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
|
||||||
async delByPrefix(prefix: string): Promise<void> {
|
async delByPrefix(prefix: string): Promise<void> {
|
||||||
const fullPrefix = this.prefix + prefix;
|
await this.delByPattern(`${prefix}*`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 glob 模式删除(SCAN 而非 KEYS,避免阻塞);pattern 会自动加统一前缀。 */
|
||||||
|
async delByPattern(pattern: string): Promise<void> {
|
||||||
|
const fullPattern = this.prefix + pattern;
|
||||||
let cursor = '0';
|
let cursor = '0';
|
||||||
do {
|
do {
|
||||||
const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, 'COUNT', 200);
|
const [next, keys] = await this.client.scan(cursor, 'MATCH', fullPattern, 'COUNT', 200);
|
||||||
cursor = next;
|
cursor = next;
|
||||||
if (keys.length > 0) await this.client.del(...keys);
|
if (keys.length > 0) await this.client.del(...keys);
|
||||||
} while (cursor !== '0');
|
} while (cursor !== '0');
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export const MENUS = [
|
|||||||
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
|
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
|
||||||
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
|
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
|
||||||
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
|
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
|
||||||
|
{ code: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 接口资源(每个受控接口一个,code = 权限码)。 */
|
/** 接口资源(每个受控接口一个,code = 权限码)。 */
|
||||||
@ -54,6 +55,11 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' },
|
{ code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' },
|
||||||
{ code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' },
|
{ code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' },
|
||||||
{ code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' },
|
{ code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' },
|
||||||
|
{ code: Permissions.session.list, name: '会话列表', method: 'GET', path: '/api/v1/sessions', parent: 'session' },
|
||||||
|
{ code: Permissions.session.kick, name: '强退会话', method: 'POST', path: '/api/v1/sessions/:sid/kick', parent: 'session' },
|
||||||
|
{ code: Permissions.user.kickSessions, name: '踢全部会话', method: 'POST', path: '/api/v1/users/:id/kick-sessions', parent: 'user' },
|
||||||
|
{ code: Permissions.user.lock, name: '锁定用户', method: 'POST', path: '/api/v1/users/:id/lock', parent: 'user' },
|
||||||
|
{ code: Permissions.user.unlock, name: '解锁用户', method: 'POST', path: '/api/v1/users/:id/unlock', parent: 'user' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
@ -106,33 +112,18 @@ export interface InitTenantOptions {
|
|||||||
includePlatformPerms: boolean;
|
includePlatformPerms: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
export interface SyncTenantResourcesOptions {
|
||||||
export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
prisma: PrismaClient;
|
||||||
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
|
tenantCode: string;
|
||||||
|
adminRoleCode: string;
|
||||||
|
includePlatformPerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 根组织
|
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */
|
||||||
await prisma.org.upsert({
|
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> {
|
||||||
where: { tenantCode_code: { tenantCode, code: tenantCode } },
|
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts;
|
||||||
update: {},
|
|
||||||
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 顶层角色
|
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
|
||||||
await prisma.role.upsert({
|
|
||||||
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
|
|
||||||
update: {},
|
|
||||||
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. 管理员账号(update 不重置密码/状态)
|
|
||||||
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
|
||||||
const admin = await prisma.user.upsert({
|
|
||||||
where: { tenantCode_username: { tenantCode, username: adminUsername } },
|
|
||||||
update: {},
|
|
||||||
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
|
|
||||||
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
|
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
|
||||||
const allResourceCodes: string[] = [];
|
const allResourceCodes: string[] = [];
|
||||||
for (const m of MENUS) {
|
for (const m of MENUS) {
|
||||||
@ -160,7 +151,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 内置字典
|
// 内置字典
|
||||||
for (const d of DICTS) {
|
for (const d of DICTS) {
|
||||||
await prisma.dictType.upsert({
|
await prisma.dictType.upsert({
|
||||||
where: { tenantCode_code: { tenantCode, code: d.code } },
|
where: { tenantCode_code: { tenantCode, code: d.code } },
|
||||||
@ -176,14 +167,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 绑定管理员到顶层角色
|
// 顶层角色全量授权
|
||||||
await prisma.userRole.upsert({
|
|
||||||
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
|
|
||||||
update: {},
|
|
||||||
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 7. 顶层角色全量授权
|
|
||||||
for (const code of allResourceCodes) {
|
for (const code of allResourceCodes) {
|
||||||
await prisma.roleResource.upsert({
|
await prisma.roleResource.upsert({
|
||||||
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
|
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
|
||||||
@ -192,3 +176,40 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
||||||
|
export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
||||||
|
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
|
||||||
|
|
||||||
|
// 1. 根组织
|
||||||
|
await prisma.org.upsert({
|
||||||
|
where: { tenantCode_code: { tenantCode, code: tenantCode } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 顶层角色
|
||||||
|
await prisma.role.upsert({
|
||||||
|
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 管理员账号(update 不重置密码/状态)
|
||||||
|
const passwordHash = await bcrypt.hash(adminPassword, 10);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { tenantCode_username: { tenantCode, username: adminUsername } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 绑定管理员到顶层角色
|
||||||
|
await prisma.userRole.upsert({
|
||||||
|
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
|
||||||
|
update: {},
|
||||||
|
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 资源 + 内置字典 + 全量授权
|
||||||
|
await syncTenantResources({ prisma, tenantCode, adminRoleCode, includePlatformPerms });
|
||||||
|
}
|
||||||
|
|||||||
15
src/sessions/dto.ts
Normal file
15
src/sessions/dto.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
import { PageQuery } from '../common/page.dto.js';
|
||||||
|
|
||||||
|
export class ListSessionQuery extends PageQuery {
|
||||||
|
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '设备 IP(模糊)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
23
src/sessions/sessions.controller.ts
Normal file
23
src/sessions/sessions.controller.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { Permissions } from '../common/permissions.js';
|
||||||
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
|
import { ListSessionQuery } from './dto.js';
|
||||||
|
import { SessionsService } from './sessions.service.js';
|
||||||
|
|
||||||
|
@Controller('sessions')
|
||||||
|
export class SessionsController {
|
||||||
|
constructor(private readonly service: SessionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions(Permissions.session.list)
|
||||||
|
list(@Query() q: ListSessionQuery) {
|
||||||
|
return this.service.listSessions(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':sid/kick')
|
||||||
|
@RequirePermissions(Permissions.session.kick)
|
||||||
|
@AuditLog('session', 'kick')
|
||||||
|
kick(@Param('sid') sid: string) {
|
||||||
|
return this.service.kickSession(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/sessions/sessions.module.ts
Normal file
10
src/sessions/sessions.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SessionsController } from './sessions.controller.js';
|
||||||
|
import { SessionsService } from './sessions.service.js';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SessionsController],
|
||||||
|
providers: [SessionsService],
|
||||||
|
exports: [SessionsService],
|
||||||
|
})
|
||||||
|
export class SessionsModule {}
|
||||||
101
src/sessions/sessions.service.ts
Normal file
101
src/sessions/sessions.service.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
|
import { sessionRevokedKey } from '../common/constants.js';
|
||||||
|
import { ListSessionQuery } from './dto.js';
|
||||||
|
|
||||||
|
/** 与 auth.service 的 access token 有效时长对齐(黑名单 TTL)。 */
|
||||||
|
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||||
|
|
||||||
|
interface SessionRow {
|
||||||
|
sid: string | null;
|
||||||
|
userId: string;
|
||||||
|
ip: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
loginAt: Date;
|
||||||
|
expireAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 活跃会话分页:数据权限过滤可见用户 + 用户名/IP 筛选。 */
|
||||||
|
async listSessions(q: ListSessionQuery) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
|
||||||
|
// 1. 数据权限 → 允许的 userId 集合(null=不限)
|
||||||
|
let userIds: string[] | null = null;
|
||||||
|
if (ctx.dataScope === 'self') {
|
||||||
|
userIds = [ctx.userId];
|
||||||
|
} else if (ctx.orgCodes.length > 0) {
|
||||||
|
const users = await this.prisma.client.user.findMany({
|
||||||
|
where: { orgCode: { in: ctx.orgCodes } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
userIds = users.map((u) => u.id);
|
||||||
|
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 用户名筛选 → 进一步收窄 userId 集合
|
||||||
|
if (q.username) {
|
||||||
|
const where: Record<string, unknown> = { username: { contains: q.username } };
|
||||||
|
if (userIds) where.id = { in: userIds };
|
||||||
|
const users = await this.prisma.client.user.findMany({ where, select: { id: true } });
|
||||||
|
userIds = users.map((u) => u.id);
|
||||||
|
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. refresh token 查询(tenantCode 由 Prisma 扩展自动注入)
|
||||||
|
const where: Record<string, unknown> = { revokedAt: null, expireAt: { gt: new Date() } };
|
||||||
|
if (userIds) where.userId = { in: userIds };
|
||||||
|
if (q.ip) where.ip = { contains: q.ip };
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.client.refreshToken.findMany({
|
||||||
|
where,
|
||||||
|
skip: (q.page - 1) * q.pageSize,
|
||||||
|
take: q.pageSize,
|
||||||
|
orderBy: { loginAt: 'desc' },
|
||||||
|
select: { sid: true, userId: true, ip: true, userAgent: true, loginAt: true, expireAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.client.refreshToken.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. 补用户名/昵称
|
||||||
|
const ids = [...new Set((rows as SessionRow[]).map((r) => r.userId))];
|
||||||
|
const users = ids.length
|
||||||
|
? await this.prisma.client.user.findMany({ where: { id: { in: ids } }, select: { id: true, username: true, nickname: true } })
|
||||||
|
: [];
|
||||||
|
const userMap = new Map(users.map((u) => [u.id, u]));
|
||||||
|
const list = (rows as SessionRow[]).map((r) => {
|
||||||
|
const u = userMap.get(r.userId);
|
||||||
|
return {
|
||||||
|
sid: r.sid,
|
||||||
|
userId: r.userId,
|
||||||
|
username: u?.username ?? '',
|
||||||
|
nickname: u?.nickname ?? null,
|
||||||
|
ip: r.ip,
|
||||||
|
userAgent: r.userAgent,
|
||||||
|
loginAt: r.loginAt,
|
||||||
|
expireAt: r.expireAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { list, total, page: q.page, pageSize: q.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */
|
||||||
|
async kickSession(sid: string): Promise<null> {
|
||||||
|
const result = await this.prisma.client.refreshToken.updateMany({
|
||||||
|
where: { sid, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (result.count === 0) throw new NotFoundException('会话不存在或已下线');
|
||||||
|
await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -88,6 +88,13 @@ export class AssignRolesDto {
|
|||||||
roleCodes!: string[];
|
roleCodes!: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LockUserDto {
|
||||||
|
@ApiPropertyOptional({ description: '锁定原因' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ListUserQuery extends PageQuery {
|
export class ListUserQuery extends PageQuery {
|
||||||
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||||
import { Permissions } from '../common/permissions.js';
|
import { Permissions } from '../common/permissions.js';
|
||||||
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
|
||||||
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
import { AssignRolesDto, CreateUserDto, ListUserQuery, LockUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
import { BatchDeleteDto } from '../common/page.dto.js';
|
import { BatchDeleteDto } from '../common/page.dto.js';
|
||||||
import { UsersService } from './users.service.js';
|
import { UsersService } from './users.service.js';
|
||||||
|
|
||||||
@ -68,4 +68,25 @@ export class UsersController {
|
|||||||
batchDelete(@Body() dto: BatchDeleteDto) {
|
batchDelete(@Body() dto: BatchDeleteDto) {
|
||||||
return this.service.batchDelete(dto.ids);
|
return this.service.batchDelete(dto.ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/lock')
|
||||||
|
@RequirePermissions(Permissions.user.lock)
|
||||||
|
@AuditLog('user', 'lock')
|
||||||
|
lock(@Param('id') id: string, @Body() dto: LockUserDto) {
|
||||||
|
return this.service.lock(id, dto.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/unlock')
|
||||||
|
@RequirePermissions(Permissions.user.unlock)
|
||||||
|
@AuditLog('user', 'unlock')
|
||||||
|
unlock(@Param('id') id: string) {
|
||||||
|
return this.service.unlock(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/kick-sessions')
|
||||||
|
@RequirePermissions(Permissions.user.kickSessions)
|
||||||
|
@AuditLog('user', 'kick-sessions')
|
||||||
|
kickSessions(@Param('id') id: string) {
|
||||||
|
return this.service.kickSessions(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ 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 { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.js';
|
import { getRequestContext } from '../common/context/request-context.js';
|
||||||
import { permCacheKey, userStateCacheKey } from '../common/constants.js';
|
import { LOGIN_FAIL_PREFIX, permCacheKey, userStateCacheKey } from '../common/constants.js';
|
||||||
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -94,6 +94,31 @@ export class UsersService extends BaseCrudService {
|
|||||||
return { total: ids.length, success: ids.length - failed.length, failed };
|
return { total: ids.length, success: ids.length - failed.length, failed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 锁定用户:记录锁定时间/原因并踢出全部会话。 */
|
||||||
|
async lock(userId: string, reason?: string): Promise<Record<string, unknown>> {
|
||||||
|
await this.db().update({ where: { id: userId }, data: { lockedAt: new Date(), lockReason: reason ?? null } });
|
||||||
|
await this.invalidateSessions(userId);
|
||||||
|
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解锁用户:清空锁定并清除登录失败计数。 */
|
||||||
|
async unlock(userId: string): Promise<Record<string, unknown>> {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const user = await this.db().findFirst({ where: { id: userId }, select: { username: true } });
|
||||||
|
await this.db().update({ where: { id: userId }, data: { lockedAt: null, lockReason: null } });
|
||||||
|
if (user?.username) {
|
||||||
|
await this.redis.delByPattern(`${LOGIN_FAIL_PREFIX}*:${user.username}`);
|
||||||
|
}
|
||||||
|
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
|
||||||
|
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 踢出用户全部会话。 */
|
||||||
|
async kickSessions(userId: string): Promise<null> {
|
||||||
|
await this.invalidateSessions(userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
|
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
|
||||||
private async invalidateSessions(userId: string): Promise<void> {
|
private async invalidateSessions(userId: string): Promise<void> {
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user