111 lines
2.9 KiB
TypeScript
111 lines
2.9 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { eq, and, gt } from "drizzle-orm";
|
|
import type { Context } from "hono";
|
|
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
|
|
import { db } from "../db/client.js";
|
|
import { sessions, users, type User } from "../db/schema.js";
|
|
|
|
const SESSION_COOKIE = "anydrop_session";
|
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
|
|
function getSessionSecret(): string {
|
|
const secret = process.env.SESSION_SECRET;
|
|
if (!secret || secret.length < 32) {
|
|
throw new Error("SESSION_SECRET env var must be set and at least 32 chars");
|
|
}
|
|
return secret;
|
|
}
|
|
|
|
function isSecureEnv(): boolean {
|
|
const url = process.env.APP_URL ?? "";
|
|
return url.startsWith("https://");
|
|
}
|
|
|
|
export function generateToken(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
export function hashToken(token: string): string {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
export async function createSession(
|
|
userId: string,
|
|
userAgent: string | undefined,
|
|
ipHash: string | undefined,
|
|
): Promise<string> {
|
|
const token = generateToken();
|
|
const tokenHash = hashToken(token);
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
|
|
await db.insert(sessions).values({
|
|
userId,
|
|
tokenHash,
|
|
userAgent: userAgent ?? null,
|
|
ipHash: ipHash ?? null,
|
|
expiresAt,
|
|
});
|
|
|
|
return token;
|
|
}
|
|
|
|
export async function setSessionCookie(c: Context, token: string): Promise<void> {
|
|
await setSignedCookie(c, SESSION_COOKIE, token, getSessionSecret(), {
|
|
httpOnly: true,
|
|
secure: isSecureEnv(),
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
maxAge: SESSION_TTL_MS / 1000,
|
|
});
|
|
}
|
|
|
|
export function clearSessionCookie(c: Context): void {
|
|
deleteCookie(c, SESSION_COOKIE, {
|
|
path: "/",
|
|
secure: isSecureEnv(),
|
|
});
|
|
}
|
|
|
|
export async function resolveSession(c: Context): Promise<User | null> {
|
|
let token: string | false | undefined;
|
|
try {
|
|
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!token) return null;
|
|
|
|
const tokenHash = hashToken(token);
|
|
const rows = await db
|
|
.select({
|
|
user: users,
|
|
sessionId: sessions.id,
|
|
})
|
|
.from(sessions)
|
|
.innerJoin(users, eq(users.id, sessions.userId))
|
|
.where(and(eq(sessions.tokenHash, tokenHash), gt(sessions.expiresAt, new Date())))
|
|
.limit(1);
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
await db
|
|
.update(sessions)
|
|
.set({ lastUsedAt: new Date() })
|
|
.where(eq(sessions.id, rows[0].sessionId));
|
|
|
|
return rows[0].user;
|
|
}
|
|
|
|
export async function revokeSession(c: Context): Promise<void> {
|
|
let token: string | false | undefined;
|
|
try {
|
|
token = await getSignedCookie(c, getSessionSecret(), SESSION_COOKIE);
|
|
} catch {
|
|
token = undefined;
|
|
}
|
|
if (token) {
|
|
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
|
|
}
|
|
clearSessionCookie(c);
|
|
}
|