diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 624699c..553778e 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -40,7 +40,7 @@ export class JwtAuthGuard implements CanActivate { if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) { throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED); } - if (mustChangePassword && !this.isChangePassword(request)) { + if (mustChangePassword && !this.isAllowedWhileMustChange(request)) { throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN); } request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser; @@ -58,10 +58,11 @@ export class JwtAuthGuard implements CanActivate { return type === 'Bearer' && token ? token : undefined; } - /** 待改密门禁白名单:仅「修改本人密码」(PATCH /auth/me/password)。 */ - private isChangePassword(request: { method?: string; originalUrl?: string; url?: string }): boolean { - if (request.method !== 'PATCH') return false; - const url: string = request.originalUrl ?? request.url ?? ''; - return url.split('?')[0].endsWith('/auth/me/password'); + /** 待改密门禁白名单:修改本人密码 + 登出(登出目前 @Public,此处兜底防未来改注解)。 */ + private isAllowedWhileMustChange(request: { method?: string; originalUrl?: string; url?: string }): boolean { + const url: string = (request.originalUrl ?? request.url ?? '').split('?')[0]; + if (request.method === 'PATCH' && url.endsWith('/auth/me/password')) return true; + if (request.method === 'POST' && url.endsWith('/auth/logout')) return true; + return false; } } diff --git a/src/common/page.dto.ts b/src/common/page.dto.ts index c914a0f..bd5d754 100644 --- a/src/common/page.dto.ts +++ b/src/common/page.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; +import { ArrayMaxSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; export class PageQuery { @ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 }) @@ -30,5 +30,6 @@ export class BatchDeleteDto { @IsArray() @IsString({ each: true }) @IsNotEmpty() + @ArrayMaxSize(100) ids!: string[]; } diff --git a/src/common/permissions.ts b/src/common/permissions.ts index 6c23d76..ad1dc3c 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -26,6 +26,7 @@ export const Permissions = { update: 'role:update', delete: 'role:delete', assignResource: 'role:assign-resource', + assignOrg: 'role:assign-org', }, resource: { list: 'resource:list', diff --git a/src/common/tenant-template.ts b/src/common/tenant-template.ts index bc0c93b..2878d40 100644 --- a/src/common/tenant-template.ts +++ b/src/common/tenant-template.ts @@ -38,6 +38,7 @@ export const APIS: { code: string; name: string; method: string; path: string; p { code: Permissions.role.update, name: '更新角色', method: 'PATCH', path: '/api/v1/roles/:id', parent: 'role' }, { code: Permissions.role.delete, name: '删除角色', method: 'DELETE', path: '/api/v1/roles/:id', parent: 'role' }, { code: Permissions.role.assignResource, name: '分配资源', method: 'PUT', path: '/api/v1/roles/:id/resources', parent: 'role' }, + { code: Permissions.role.assignOrg, name: '分配数据权限部门', method: 'PUT', path: '/api/v1/roles/:id/orgs', parent: 'role' }, { code: Permissions.resource.list, name: '资源列表', method: 'GET', path: '/api/v1/resources', parent: 'resource' }, { code: Permissions.resource.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' }, { code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', parent: 'resource' }, diff --git a/src/notices/notices.service.ts b/src/notices/notices.service.ts index 938efd2..2749d7c 100644 --- a/src/notices/notices.service.ts +++ b/src/notices/notices.service.ts @@ -50,7 +50,7 @@ export class NoticesService extends BaseCrudService { return { list, total, page: q.page, pageSize: q.pageSize }; } - /** 未读数 = enabled 通知数 − 当前用户已读数。 */ + /** 未读数 = enabled 通知数 − 当前用户已读数(O(enabled 通知数),公告量小可接受)。 */ async unreadCount(): Promise { const ctx = getRequestContext()!; const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } }); diff --git a/src/roles/roles.controller.ts b/src/roles/roles.controller.ts index 91dff0e..4cd3752 100644 --- a/src/roles/roles.controller.ts +++ b/src/roles/roles.controller.ts @@ -61,7 +61,7 @@ export class RolesController { } @Put(':roleCode/orgs') - @RequirePermissions(Permissions.role.update) + @RequirePermissions(Permissions.role.assignOrg) @AuditLog('role', 'assign-org') assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) { return this.service.assignOrgs(roleCode, dto); diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index e6a492f..1a783fb 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -82,6 +82,18 @@ export class RolesService extends BaseCrudService { const ctx = getRequestContext()!; const role = await this.db().findFirst({ where: { code: roleCode } }); if (!role) throw new BadRequestException('角色不存在'); + // 校验 orgCodes 存在性(避免静默写入不存在/已删的组织) + const existing = dto.orgCodes.length + ? await this.prisma.client.org.findMany({ + where: { tenantCode: ctx.tenantCode, code: { in: dto.orgCodes }, deletedAt: null }, + select: { code: true }, + }) + : []; + const validOrgCodes = existing.map((r) => r.code); + if (validOrgCodes.length !== dto.orgCodes.length) { + const invalid = dto.orgCodes.filter((c) => !validOrgCodes.includes(c)); + throw new BadRequestException(`组织不存在:${invalid.join(', ')}`); + } await this.prisma.raw.$transaction(async (tx) => { await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } }); if (dto.orgCodes.length > 0) { diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts index 16b8bc1..bd9df63 100644 --- a/src/sessions/sessions.service.ts +++ b/src/sessions/sessions.service.ts @@ -1,8 +1,8 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; +import { ForbiddenException, HttpStatus, Injectable } 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 { RESERVED_ROLE_CODES, sessionRevokedKey } from '../common/constants.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { ListSessionQuery } from './dto.js'; @@ -92,6 +92,19 @@ export class SessionsService { /** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */ async kickSession(sid: string): Promise { + const ctx = getRequestContext()!; + const session = await this.prisma.client.refreshToken.findFirst({ + where: { sid, revokedAt: null }, + select: { userId: true }, + }); + if (!session) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND); + const roles = await this.prisma.client.userRole.findMany({ + where: { tenantCode: ctx.tenantCode, userId: session.userId }, + select: { roleCode: true }, + }); + if (roles.some((r) => RESERVED_ROLE_CODES.includes(r.roleCode))) { + throw new ForbiddenException('保留角色账号的会话不可强退'); + } const result = await this.prisma.client.refreshToken.updateMany({ where: { sid, revokedAt: null }, data: { revokedAt: new Date() }, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 687f4d1..37351b2 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 { LOGIN_FAIL_PREFIX, permCacheKey, userStateCacheKey } from '../common/constants.js'; +import { LOGIN_FAIL_PREFIX, permCacheKey, RESERVED_ROLE_CODES, userStateCacheKey } from '../common/constants.js'; import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js'; @Injectable() @@ -96,6 +96,7 @@ export class UsersService extends BaseCrudService { /** 锁定用户:记录锁定时间/原因并踢出全部会话。 */ async lock(userId: string, reason?: string): Promise> { + if (await this.hasReservedRole(userId)) throw new ForbiddenException('保留角色账号不可锁定'); 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 } })); @@ -115,10 +116,21 @@ export class UsersService extends BaseCrudService { /** 踢出用户全部会话。 */ async kickSessions(userId: string): Promise { + if (await this.hasReservedRole(userId)) throw new ForbiddenException('保留角色账号不可踢出'); await this.invalidateSessions(userId); return null; } + /** 目标用户是否持有保留角色(super_admin/tenant_super_admin)。 */ + private async hasReservedRole(userId: string): Promise { + const ctx = getRequestContext()!; + const rows = await this.prisma.client.userRole.findMany({ + where: { tenantCode: ctx.tenantCode, userId }, + select: { roleCode: true }, + }); + return rows.some((r) => RESERVED_ROLE_CODES.includes(r.roleCode)); + } + /** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */ private async invalidateSessions(userId: string): Promise { const ctx = getRequestContext();