diff --git a/package.json b/package.json index afe386a..8d1dab4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20261011132906_session_and_lock/migration.sql b/prisma/migrations/20261011132906_session_and_lock/migration.sql new file mode 100644 index 0000000..5e67b86 --- /dev/null +++ b/prisma/migrations/20261011132906_session_and_lock/migration.sql @@ -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`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22bcd4d..47f6a6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) @@ -50,17 +52,17 @@ model User { /// 角色 model Role { - id String @id @default(uuid(7)) @db.VarChar(36) - tenantCode String @map("tenant_code") @db.VarChar(64) - code String @db.VarChar(64) - name String @db.VarChar(64) - dataScope String @default("all") @map("data_scope") @db.VarChar(16) - status String @default("enabled") @db.VarChar(16) - remark String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - createdBy String? @map("created_by") @db.VarChar(36) - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid(7)) @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + code String @db.VarChar(64) + name String @db.VarChar(64) + dataScope String @default("all") @map("data_scope") @db.VarChar(16) + status String @default("enabled") @db.VarChar(16) + remark String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") @db.VarChar(36) + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") @@unique([tenantCode, code]) @@index([tenantCode]) @@ -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,15 +226,16 @@ model RefreshToken { createdAt DateTime @default(now()) @map("created_at") @@index([tenantCode, userId]) + @@index([sid]) @@map("sys_refresh_token") } /// 用户-角色关联 model UserRole { - 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) - roleCode String @map("role_code") @db.VarChar(64) + 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) + roleCode String @map("role_code") @db.VarChar(64) @@unique([tenantCode, userId, roleCode]) @@index([tenantCode, userId]) diff --git a/prisma/sync-resources.ts b/prisma/sync-resources.ts new file mode 100644 index 0000000..e7dd8bb --- /dev/null +++ b/prisma/sync-resources.ts @@ -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 { + 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(); + }); diff --git a/src/app.module.ts b/src/app.module.ts index 21b29ff..34661fa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0706d40..a97fe18 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { @@ -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 { 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 { + 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), }, }); diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 0a9f206..58606ce 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -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) { diff --git a/src/common/constants.ts b/src/common/constants.ts index 3fbbe20..97858bb 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -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:'; diff --git a/src/common/enums.ts b/src/common/enums.ts index 801ea35..868d792 100644 --- a/src/common/enums.ts +++ b/src/common/enums.ts @@ -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]; diff --git a/src/common/permissions.ts b/src/common/permissions.ts index a0680f9..b0285c9 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -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; diff --git a/src/common/redis/redis.service.ts b/src/common/redis/redis.service.ts index 65ec1c2..6b3cb92 100644 --- a/src/common/redis/redis.service.ts +++ b/src/common/redis/redis.service.ts @@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy { /** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */ async delByPrefix(prefix: string): Promise { - const fullPrefix = this.prefix + prefix; + await this.delByPattern(`${prefix}*`); + } + + /** 按 glob 模式删除(SCAN 而非 KEYS,避免阻塞);pattern 会自动加统一前缀。 */ + async delByPattern(pattern: string): Promise { + 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'); diff --git a/src/common/tenant-template.ts b/src/common/tenant-template.ts index aabaf2c..57cd771 100644 --- a/src/common/tenant-template.ts +++ b/src/common/tenant-template.ts @@ -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 { - 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 { + 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 { }); } - // 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 { } } - // 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 { }); } } + +/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */ +export async function initTenant(opts: InitTenantOptions): Promise { + 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 }); +} diff --git a/src/sessions/dto.ts b/src/sessions/dto.ts new file mode 100644 index 0000000..0092fff --- /dev/null +++ b/src/sessions/dto.ts @@ -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; +} diff --git a/src/sessions/sessions.controller.ts b/src/sessions/sessions.controller.ts new file mode 100644 index 0000000..1bba5ea --- /dev/null +++ b/src/sessions/sessions.controller.ts @@ -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); + } +} diff --git a/src/sessions/sessions.module.ts b/src/sessions/sessions.module.ts new file mode 100644 index 0000000..69bc98e --- /dev/null +++ b/src/sessions/sessions.module.ts @@ -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 {} diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts new file mode 100644 index 0000000..b2c1e50 --- /dev/null +++ b/src/sessions/sessions.service.ts @@ -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 = { 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 = { 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 { + 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; + } +} diff --git a/src/users/dto.ts b/src/users/dto.ts index 376b4e0..c2aa975 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -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() diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 59fb309..816d9f9 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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); + } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 23e759e..2c4e860 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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> { + 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> { + 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 { + await this.invalidateSessions(userId); + return null; + } + /** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */ private async invalidateSessions(userId: string): Promise { const ctx = getRequestContext();