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:debug": "vitest --inspect-brk --no-file-parallelism",
|
||||
"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": {
|
||||
"@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)
|
||||
status String @default("enabled") @db.VarChar(16)
|
||||
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)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String? @map("created_by") @db.VarChar(36)
|
||||
@ -213,6 +215,7 @@ model RefreshToken {
|
||||
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
sid String? @db.VarChar(36)
|
||||
jti String @unique @db.VarChar(64)
|
||||
tokenHash String @map("token_hash") @db.VarChar(255)
|
||||
ip String? @db.VarChar(64)
|
||||
@ -223,6 +226,7 @@ model RefreshToken {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([tenantCode, userId])
|
||||
@@index([sid])
|
||||
@@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 { AuditLogsModule } from './audit-logs/audit-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 { AppService } from './app.service.js';
|
||||
|
||||
@ -43,6 +44,7 @@ import { AppService } from './app.service.js';
|
||||
ConfigsModule,
|
||||
AuditLogsModule,
|
||||
LoginLogsModule,
|
||||
SessionsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@ -10,12 +10,11 @@ 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, 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 ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||
|
||||
const LOGIN_FAIL_PREFIX = 'loginFail:';
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const MAX_GLOBAL_LOGIN_FAILS = 20;
|
||||
const LOGIN_LOCK_SECONDS = 900;
|
||||
@ -103,11 +102,11 @@ export class AuthService {
|
||||
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
|
||||
const users = await this.prisma.client.user.findMany({
|
||||
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 }[] = [];
|
||||
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 } });
|
||||
if (!tenant || tenant.status !== 'enabled') continue;
|
||||
if (await bcrypt.compare(dto.password, u.passwordHash)) {
|
||||
@ -175,9 +174,13 @@ export class AuthService {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
}
|
||||
if (user.lockedAt) {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent);
|
||||
throw new UnauthorizedException('账号已锁定');
|
||||
}
|
||||
if (user.status !== 'enabled') {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
|
||||
throw new UnauthorizedException('账号已停用或锁定');
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
|
||||
throw new UnauthorizedException('账号已停用');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
@ -196,7 +199,12 @@ export class AuthService {
|
||||
ip: ip ?? 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({
|
||||
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,并重聚合权限集(权限变更在刷新时生效)
|
||||
await this.prisma.client.refreshToken.update({
|
||||
@ -260,7 +268,12 @@ export class AuthService {
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
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> {
|
||||
@ -389,20 +402,29 @@ export class AuthService {
|
||||
return permCacheKey(tenantCode, userId);
|
||||
}
|
||||
|
||||
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
|
||||
/** 校验用户当前是否可用(status=enabled、未锁定且 tokenVersion 与签发时一致)。 */
|
||||
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
|
||||
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) {
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode, id: userId, deletedAt: null },
|
||||
select: { status: true, tokenVersion: true },
|
||||
select: { status: true, tokenVersion: true, lockedAt: true },
|
||||
});
|
||||
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);
|
||||
}
|
||||
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,
|
||||
username: string,
|
||||
tokenVersion: number,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
|
||||
): 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();
|
||||
await this.prisma.client.refreshToken.create({
|
||||
data: {
|
||||
tenantCode,
|
||||
userId,
|
||||
sid: session.sid,
|
||||
jti,
|
||||
tokenHash: createHash('sha256').update(jti).digest('hex'),
|
||||
ip: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
ip: session.ip ?? null,
|
||||
userAgent: session.userAgent ?? null,
|
||||
loginAt: session.loginAt,
|
||||
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
@ -32,9 +32,12 @@ export class JwtAuthGuard implements CanActivate {
|
||||
const token = this.extractToken(request);
|
||||
if (!token) throw new UnauthorizedException('未登录');
|
||||
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);
|
||||
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;
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@ -20,5 +20,11 @@ export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
|
||||
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
|
||||
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}`;
|
||||
|
||||
/** 会话吊销黑名单 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 = {
|
||||
ENABLED: 'enabled',
|
||||
DISABLED: 'disabled',
|
||||
LOCKED: 'locked',
|
||||
} as const;
|
||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
|
||||
|
||||
|
||||
@ -16,6 +16,9 @@ export const Permissions = {
|
||||
delete: 'user:delete',
|
||||
resetPwd: 'user:reset-pwd',
|
||||
assignRole: 'user:assign-role',
|
||||
kickSessions: 'user:kick-sessions',
|
||||
lock: 'user:lock',
|
||||
unlock: 'user:unlock',
|
||||
},
|
||||
role: {
|
||||
list: 'role:list',
|
||||
@ -48,6 +51,7 @@ export const Permissions = {
|
||||
update: 'config:update',
|
||||
delete: 'config:delete',
|
||||
},
|
||||
session: { list: 'session:list', kick: 'session:kick' },
|
||||
audit: { list: 'audit:list' },
|
||||
loginLog: { list: 'login-log:list' },
|
||||
} as const;
|
||||
|
||||
@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy {
|
||||
|
||||
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
|
||||
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';
|
||||
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;
|
||||
if (keys.length > 0) await this.client.del(...keys);
|
||||
} 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: '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: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
|
||||
];
|
||||
|
||||
/** 接口资源(每个受控接口一个,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.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.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;
|
||||
}
|
||||
|
||||
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
|
||||
export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
||||
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
|
||||
export interface SyncTenantResourcesOptions {
|
||||
prisma: PrismaClient;
|
||||
tenantCode: string;
|
||||
adminRoleCode: string;
|
||||
includePlatformPerms: boolean;
|
||||
}
|
||||
|
||||
// 1. 根组织
|
||||
await prisma.org.upsert({
|
||||
where: { tenantCode_code: { tenantCode, code: tenantCode } },
|
||||
update: {},
|
||||
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
|
||||
});
|
||||
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */
|
||||
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> {
|
||||
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts;
|
||||
|
||||
// 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. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
|
||||
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
|
||||
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
|
||||
const allResourceCodes: string[] = [];
|
||||
for (const m of MENUS) {
|
||||
@ -160,7 +151,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 内置字典
|
||||
// 内置字典
|
||||
for (const d of DICTS) {
|
||||
await prisma.dictType.upsert({
|
||||
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) {
|
||||
await prisma.roleResource.upsert({
|
||||
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[];
|
||||
}
|
||||
|
||||
export class LockUserDto {
|
||||
@ApiPropertyOptional({ description: '锁定原因' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ListUserQuery extends PageQuery {
|
||||
@ApiPropertyOptional({ description: '用户名(模糊)' })
|
||||
@IsOptional()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { Permissions } from '../common/permissions.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 { UsersService } from './users.service.js';
|
||||
|
||||
@ -68,4 +68,25 @@ export class UsersController {
|
||||
batchDelete(@Body() dto: BatchDeleteDto) {
|
||||
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 { RedisService } from '../common/redis/redis.service.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';
|
||||
|
||||
@Injectable()
|
||||
@ -94,6 +94,31 @@ export class UsersService extends BaseCrudService {
|
||||
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 + 清缓存。 */
|
||||
private async invalidateSessions(userId: string): Promise<void> {
|
||||
const ctx = getRequestContext();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user