fix(security): 若干越权/健壮性修复

- assignOrgs 校验 orgCodes 存在性(对齐 assignResources)
- lock/kick 保护保留角色账号(super_admin/tenant_super_admin)
- BatchDeleteDto 加 @ArrayMaxSize(100)
- 待改密门禁显式放行 /auth/logout
- 分配部门改用独立权限码 role:assign-org

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 22:56:12 +08:00
parent 8eefa7fa44
commit 477f35293e
9 changed files with 53 additions and 12 deletions

View File

@ -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;
}
}

View File

@ -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[];
}

View File

@ -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',

View File

@ -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' },

View File

@ -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<number> {
const ctx = getRequestContext()!;
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });

View File

@ -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);

View File

@ -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) {

View File

@ -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<null> {
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() },

View File

@ -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<Record<string, unknown>> {
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<null> {
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<boolean> {
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<void> {
const ctx = getRequestContext();