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))) {
|
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
|
||||||
throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED);
|
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);
|
throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
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;
|
return type === 'Bearer' && token ? token : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 待改密门禁白名单:仅「修改本人密码」(PATCH /auth/me/password)。 */
|
/** 待改密门禁白名单:修改本人密码 + 登出(登出目前 @Public,此处兜底防未来改注解)。 */
|
||||||
private isChangePassword(request: { method?: string; originalUrl?: string; url?: string }): boolean {
|
private isAllowedWhileMustChange(request: { method?: string; originalUrl?: string; url?: string }): boolean {
|
||||||
if (request.method !== 'PATCH') return false;
|
const url: string = (request.originalUrl ?? request.url ?? '').split('?')[0];
|
||||||
const url: string = request.originalUrl ?? request.url ?? '';
|
if (request.method === 'PATCH' && url.endsWith('/auth/me/password')) return true;
|
||||||
return url.split('?')[0].endsWith('/auth/me/password');
|
if (request.method === 'POST' && url.endsWith('/auth/logout')) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class PageQuery {
|
||||||
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
|
@ApiPropertyOptional({ description: '页码(从 1 开始)', default: 1 })
|
||||||
@ -30,5 +30,6 @@ export class BatchDeleteDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
ids!: string[];
|
ids!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const Permissions = {
|
|||||||
update: 'role:update',
|
update: 'role:update',
|
||||||
delete: 'role:delete',
|
delete: 'role:delete',
|
||||||
assignResource: 'role:assign-resource',
|
assignResource: 'role:assign-resource',
|
||||||
|
assignOrg: 'role:assign-org',
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
list: 'resource:list',
|
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.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.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.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.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.create, name: '创建资源', method: 'POST', path: '/api/v1/resources', parent: 'resource' },
|
||||||
{ code: Permissions.resource.update, name: '更新资源', method: 'PATCH', path: '/api/v1/resources/:id', 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 };
|
return { list, total, page: q.page, pageSize: q.pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未读数 = enabled 通知数 − 当前用户已读数。 */
|
/** 未读数 = enabled 通知数 − 当前用户已读数(O(enabled 通知数),公告量小可接受)。 */
|
||||||
async unreadCount(): Promise<number> {
|
async unreadCount(): Promise<number> {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
|
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
|
||||||
|
|||||||
@ -61,7 +61,7 @@ export class RolesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':roleCode/orgs')
|
@Put(':roleCode/orgs')
|
||||||
@RequirePermissions(Permissions.role.update)
|
@RequirePermissions(Permissions.role.assignOrg)
|
||||||
@AuditLog('role', 'assign-org')
|
@AuditLog('role', 'assign-org')
|
||||||
assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) {
|
assignOrgs(@Param('roleCode') roleCode: string, @Body() dto: AssignOrgsDto) {
|
||||||
return this.service.assignOrgs(roleCode, dto);
|
return this.service.assignOrgs(roleCode, dto);
|
||||||
|
|||||||
@ -82,6 +82,18 @@ export class RolesService extends BaseCrudService {
|
|||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const role = await this.db().findFirst({ where: { code: roleCode } });
|
const role = await this.db().findFirst({ where: { code: roleCode } });
|
||||||
if (!role) throw new BadRequestException('角色不存在');
|
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 this.prisma.raw.$transaction(async (tx) => {
|
||||||
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
await tx.roleOrg.deleteMany({ where: { tenantCode: ctx.tenantCode, roleCode } });
|
||||||
if (dto.orgCodes.length > 0) {
|
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 { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { RedisService } from '../common/redis/redis.service.js';
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.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 { BusinessException } from '../common/business-exception.js';
|
||||||
import { ErrorCodes } from '../common/error-codes.js';
|
import { ErrorCodes } from '../common/error-codes.js';
|
||||||
import { ListSessionQuery } from './dto.js';
|
import { ListSessionQuery } from './dto.js';
|
||||||
@ -92,6 +92,19 @@ export class SessionsService {
|
|||||||
|
|
||||||
/** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */
|
/** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */
|
||||||
async kickSession(sid: string): Promise<null> {
|
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({
|
const result = await this.prisma.client.refreshToken.updateMany({
|
||||||
where: { sid, revokedAt: null },
|
where: { sid, revokedAt: null },
|
||||||
data: { revokedAt: new Date() },
|
data: { revokedAt: new Date() },
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { BaseCrudService } from '../common/base-crud.service.js';
|
|||||||
import { PrismaService } from '../prisma/prisma.service.js';
|
import { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { RedisService } from '../common/redis/redis.service.js';
|
import { RedisService } from '../common/redis/redis.service.js';
|
||||||
import { getRequestContext } from '../common/context/request-context.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';
|
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -96,6 +96,7 @@ export class UsersService extends BaseCrudService {
|
|||||||
|
|
||||||
/** 锁定用户:记录锁定时间/原因并踢出全部会话。 */
|
/** 锁定用户:记录锁定时间/原因并踢出全部会话。 */
|
||||||
async lock(userId: string, reason?: string): Promise<Record<string, unknown>> {
|
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.db().update({ where: { id: userId }, data: { lockedAt: new Date(), lockReason: reason ?? null } });
|
||||||
await this.invalidateSessions(userId);
|
await this.invalidateSessions(userId);
|
||||||
return this.omitPasswordHash(await this.db().findUnique({ where: { id: 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> {
|
async kickSessions(userId: string): Promise<null> {
|
||||||
|
if (await this.hasReservedRole(userId)) throw new ForbiddenException('保留角色账号不可踢出');
|
||||||
await this.invalidateSessions(userId);
|
await this.invalidateSessions(userId);
|
||||||
return null;
|
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 + 清缓存。 */
|
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
|
||||||
private async invalidateSessions(userId: string): Promise<void> {
|
private async invalidateSessions(userId: string): Promise<void> {
|
||||||
const ctx = getRequestContext();
|
const ctx = getRequestContext();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user