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:
parent
8eefa7fa44
commit
477f35293e
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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 } });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() },
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user