106 lines
4.2 KiB
TypeScript
106 lines
4.2 KiB
TypeScript
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
|
|
|
export type SessionRequest = {
|
|
header(name: string): string | undefined;
|
|
originalUrl?: string;
|
|
url?: string;
|
|
sessionUserId?: string;
|
|
sessionToken?: string;
|
|
sessionTenantId?: string;
|
|
authSession?: AuthSessionRecord;
|
|
};
|
|
|
|
@Injectable()
|
|
export class SessionValidationMiddleware implements NestMiddleware {
|
|
constructor(private readonly prisma: PrismaService, private readonly sessions: SessionService) {}
|
|
|
|
async use(request: SessionRequest, _: unknown, next: () => void) {
|
|
const path = request.originalUrl ?? request.url ?? '';
|
|
if (/\/(admin|client)\/auth\/(captcha|login)(?:\?|$)/.test(path)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const portal = this.portalForPath(path);
|
|
const token = portal ? this.readCookie(request.header('cookie'), portal) : undefined;
|
|
if (!token) {
|
|
if (portal && !path.includes('/admin/gateway/')) {
|
|
throw this.unauthorized('SESSION_INVALID', '请先登录');
|
|
}
|
|
next();
|
|
return;
|
|
}
|
|
const result = await this.sessions.validate(token, request.header('x-session-activity') === 'user');
|
|
if (result.status === 'expired') {
|
|
throw this.unauthorized(result.code, result.code === 'SESSION_ABSOLUTE_TIMEOUT' ? '登录已满 12 小时,请重新登录' : '登录会话已失效,请重新登录');
|
|
}
|
|
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: result.record.userId },
|
|
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
|
|
});
|
|
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
|
await this.sessions.remove(token);
|
|
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
|
|
}
|
|
if (result.record.portal !== portal) {
|
|
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
|
|
}
|
|
|
|
request.sessionUserId = user.id;
|
|
request.sessionToken = token;
|
|
request.authSession = result.record;
|
|
if (portal === 'client') {
|
|
if (!user.tenantId) {
|
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
|
|
}
|
|
const suppliedTenantId = request.header('x-tenant-id')?.trim();
|
|
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
|
|
await this.prisma.operationLog.create({
|
|
data: {
|
|
tenantId: user.tenantId,
|
|
userId: user.id,
|
|
action: 'security.client_tenant_mismatch',
|
|
resource: 'auth_session',
|
|
detail: { suppliedTenantId },
|
|
},
|
|
});
|
|
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
|
|
}
|
|
request.sessionTenantId = user.tenantId;
|
|
}
|
|
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
|
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
|
if (result.newlyLocked) {
|
|
await this.prisma.operationLog.create({
|
|
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
|
|
});
|
|
}
|
|
throw this.unauthorized('SESSION_LOCKED', '由于长时间未操作,会话已安全锁定');
|
|
}
|
|
next();
|
|
}
|
|
|
|
private portalForPath(path: string): SessionPortal | undefined {
|
|
if (/\/admin\//.test(path)) return 'admin';
|
|
if (/\/client\//.test(path)) return 'client';
|
|
return undefined;
|
|
}
|
|
|
|
private readCookie(cookieHeader: string | undefined, portal: SessionPortal) {
|
|
if (!cookieHeader) return undefined;
|
|
const cookieName = this.sessions.cookieName(portal);
|
|
for (const part of cookieHeader.split(';')) {
|
|
const [name, ...value] = part.trim().split('=');
|
|
if (name === cookieName) return decodeURIComponent(value.join('='));
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private unauthorized(code: string, message: string) {
|
|
return new UnauthorizedException({ code, message });
|
|
}
|
|
}
|