ai-chatbot/lib/auth/session-control.ts
Your Name 366e0e3e5c
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: enforce single active session per account
2026-04-03 17:43:03 +08:00

150 lines
3.5 KiB
TypeScript

import "server-only";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { user } from "@/lib/db/schema";
type ManagedUserType = "guest" | "regular" | "admin" | "agent_admin";
type ManagedSessionUser = {
id: string;
email: string;
name: string | null;
role: ManagedUserType;
isActive: boolean;
activeSessionKey: string | null;
};
function normalizeClientIp(value: string | null | undefined) {
if (!value) {
return null;
}
const firstHop = value.split(",")[0]?.trim();
return firstHop || null;
}
function normalizeUserAgent(value: string | null | undefined) {
const normalized = String(value || "").trim();
if (!normalized) {
return null;
}
return normalized.slice(0, 512);
}
async function readRequestSessionMetadata() {
const requestHeaders = await headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
const realIp = requestHeaders.get("x-real-ip");
return {
ip: normalizeClientIp(forwardedFor) || normalizeClientIp(realIp),
userAgent: normalizeUserAgent(requestHeaders.get("user-agent")),
};
}
export function isManagedSessionUserType(
type: string | null | undefined
): type is ManagedUserType {
return (
type === "guest" ||
type === "regular" ||
type === "admin" ||
type === "agent_admin"
);
}
export function requiresSingleActiveSession(type: string | null | undefined) {
return type === "regular" || type === "admin" || type === "agent_admin";
}
export async function establishSingleActiveSession(params: { userId: string }) {
const { userId } = params;
const sessionKey = crypto.randomUUID();
const metadata = await readRequestSessionMetadata();
const now = new Date();
await db
.update(user)
.set({
activeSessionKey: sessionKey,
activeSessionCreatedAt: now,
activeSessionIp: metadata.ip,
activeSessionUserAgent: metadata.userAgent,
activeSessionLastSeenAt: now,
updatedAt: now,
})
.where(eq(user.id, userId));
return { sessionKey };
}
export async function getValidatedSessionUser(params: {
userId: string;
sessionKey: string;
}) {
const { userId, sessionKey } = params;
const rows = await db
.select({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
activeSessionKey: user.activeSessionKey,
})
.from(user)
.where(eq(user.id, userId))
.limit(1);
const currentUser = rows[0] as ManagedSessionUser | undefined;
if (!currentUser) {
return null;
}
if (!currentUser.isActive) {
return null;
}
if (!requiresSingleActiveSession(currentUser.role)) {
return currentUser;
}
if (
!currentUser.activeSessionKey ||
currentUser.activeSessionKey !== sessionKey
) {
return null;
}
return currentUser;
}
export async function revokeSingleActiveSession(params: {
userId: string;
sessionKey?: string | null;
}) {
const { userId, sessionKey } = params;
const now = new Date();
const whereClause = sessionKey
? and(eq(user.id, userId), eq(user.activeSessionKey, sessionKey))
: eq(user.id, userId);
const revokedUsers = await db
.update(user)
.set({
activeSessionKey: null,
activeSessionCreatedAt: null,
activeSessionIp: null,
activeSessionUserAgent: null,
activeSessionLastSeenAt: null,
updatedAt: now,
})
.where(whereClause)
.returning({ id: user.id });
return revokedUsers.length > 0;
}