ec-nest/src/sessions/sessions.service.ts
liuyun 477f35293e 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>
2026-10-11 22:56:12 +08:00

117 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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';
/** 与 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<string, unknown> = { 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<string, unknown> = { 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<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() },
});
if (result.count === 0) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND);
await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL);
return null;
}
}