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