From 103013c878b0cb4f71149cef969d7b19963a6f20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 20:50:27 +0000 Subject: [PATCH 1/2] Fix login by replacing HMAC-signed cookies with DB-backed operator sessions The HMAC cookie signing required COOKIE_SECRET env var which wasn't set, breaking all login attempts. Replaced with server-side operator_sessions table (following adminSessions pattern) that also enables session transfer: when an operator logs in from a new machine, old sessions are deleted, automatically logging out the previous machine. https://claude.ai/code/session_01Rf47gNAgM7jDstC4bvffzw --- src/lib/server/auth.ts | 53 ++++++++++++------------- src/lib/server/db/schema.ts | 9 +++++ src/routes/+layout.server.ts | 6 +-- src/routes/+page.server.ts | 2 +- src/routes/api/shifts/close/+server.ts | 10 +++-- src/routes/api/shifts/end/+server.ts | 4 +- src/routes/api/shifts/logout/+server.ts | 7 +++- src/routes/api/shifts/start/+server.ts | 5 ++- src/routes/api/store-sales/+server.ts | 8 ++-- 9 files changed, 59 insertions(+), 45 deletions(-) diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index eb2ff7c..865abde 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,11 +1,10 @@ import bcrypt from 'bcryptjs'; -import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; +import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; import { logger } from '$lib/server/logger'; import { db } from '$lib/server/db'; -import { rateLimits } from '$lib/server/db/schema'; +import { rateLimits, operatorSessions } from '$lib/server/db/schema'; import { eq, lt } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; -import { env } from '$env/dynamic/private'; import type { Cookies } from '@sveltejs/kit'; const SALT_ROUNDS = 10; @@ -82,34 +81,32 @@ export async function clearRateLimit(key: string): Promise { await db.delete(rateLimits).where(eq(rateLimits.key, key)); } -function getCookieSecret(): string { - const secret = env.COOKIE_SECRET; - if (!secret) throw new Error('COOKIE_SECRET environment variable is required'); - return secret; -} +export async function createOperatorSession(operatorId: number): Promise { + // Delete all existing sessions for this operator (transfers session to new login) + await db.delete(operatorSessions).where(eq(operatorSessions.operatorId, operatorId)); -export function signCookieValue(value: string): string { - const hmac = createHmac('sha256', getCookieSecret()).update(value).digest('hex'); - return `${value}.${hmac}`; + const token = randomBytes(32).toString('hex'); + await db.insert(operatorSessions).values({ token, operatorId }); + return token; } -export function verifyCookieValue(signed: string): string | null { - const dot = signed.lastIndexOf('.'); - if (dot === -1) return null; - const value = signed.slice(0, dot); - const sig = signed.slice(dot + 1); - const expected = createHmac('sha256', getCookieSecret()).update(value).digest('hex'); - if (sig.length !== expected.length) return null; - if (!timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null; - return value; +export async function deleteOperatorSessions(operatorId: number): Promise { + await db.delete(operatorSessions).where(eq(operatorSessions.operatorId, operatorId)); } -export function getVerifiedOperatorId(cookies: Cookies): number | null { - const raw = cookies.get('operatorId'); - if (!raw) return null; - const value = verifyCookieValue(raw); - if (!value) return null; - const id = parseInt(value); - if (isNaN(id)) return null; - return id; +export async function getVerifiedOperatorId(cookies: Cookies): Promise { + const token = cookies.get('operatorSession'); + if (!token) return null; + + const [session] = await db + .select({ operatorId: operatorSessions.operatorId }) + .from(operatorSessions) + .where(eq(operatorSessions.token, token)); + + if (!session) { + cookies.delete('operatorSession', { path: '/' }); + return null; + } + + return session.operatorId; } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index b07a692..15bc2ce 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -248,6 +248,13 @@ export const adminSessions = pgTable('admin_sessions', { createdAt: timestamp('created_at', { withTimezone: true }).defaultNow() }); +export const operatorSessions = pgTable('operator_sessions', { + id: serial('id').primaryKey(), + token: text('token').unique().notNull(), + operatorId: integer('operator_id').references(() => operators.id).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow() +}); + export type Payment = typeof payments.$inferSelect; export type NewPayment = typeof payments.$inferInsert; @@ -261,3 +268,5 @@ export type ClosingChecklistItem = typeof closingChecklistItems.$inferSelect; export type NewClosingChecklistItem = typeof closingChecklistItems.$inferInsert; export type AdminSession = typeof adminSessions.$inferSelect; export type NewAdminSession = typeof adminSessions.$inferInsert; +export type OperatorSession = typeof operatorSessions.$inferSelect; +export type NewOperatorSession = typeof operatorSessions.$inferInsert; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index f6804d4..691dc92 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -5,7 +5,7 @@ import { getVerifiedOperatorId } from '$lib/server/auth'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ cookies }) => { - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); if (!operatorId) { return { operator: null, shift: null }; @@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => { .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { - cookies.delete('operatorId', { path: '/' }); + cookies.delete('operatorSession', { path: '/' }); return { operator: null, shift: null }; } @@ -27,7 +27,7 @@ export const load: LayoutServerLoad = async ({ cookies }) => { .where(and(eq(shifts.operatorId, operatorId), isNull(shifts.endedAt))); if (!activeShift) { - cookies.delete('operatorId', { path: '/' }); + cookies.delete('operatorSession', { path: '/' }); return { operator: null, shift: null }; } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 3547ecb..0aaf5e3 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ cookies }) => { db.select().from(reservations).where(eq(reservations.status, 'active')) ]); - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); let activeRentals: Rental[] = []; let previousShiftRentals: Rental[] = []; let shiftStoreSales: Array<{ diff --git a/src/routes/api/shifts/close/+server.ts b/src/routes/api/shifts/close/+server.ts index 9f162ed..246ebef 100644 --- a/src/routes/api/shifts/close/+server.ts +++ b/src/routes/api/shifts/close/+server.ts @@ -2,7 +2,7 @@ import { db } from '$lib/server/db'; import { shifts, rentals, operators, storeSales, storeProducts, tourBookings, tourAgencyProducts } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; import { logger } from '$lib/server/logger'; -import { getVerifiedOperatorId } from '$lib/server/auth'; +import { getVerifiedOperatorId, deleteOperatorSessions } from '$lib/server/auth'; import type { RequestHandler } from './$types'; import * as XLSX from 'xlsx'; import { isGoogleConnected, createSpreadsheet } from '$lib/server/google-sheets'; @@ -52,7 +52,7 @@ interface Pricing { } export const POST: RequestHandler = async ({ cookies }) => { - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); if (!operatorId) { return new Response(JSON.stringify({ error: 'Not logged in' }), { status: 401 }); } @@ -317,7 +317,8 @@ export const POST: RequestHandler = async ({ cookies }) => { const url = await createSpreadsheet(filename, sheets); - cookies.delete('operatorId', { path: '/' }); + await deleteOperatorSessions(operatorId); + cookies.delete('operatorSession', { path: '/' }); return new Response(JSON.stringify({ type: 'google_sheets', url }), { headers: { 'Content-Type': 'application/json' } @@ -349,7 +350,8 @@ export const POST: RequestHandler = async ({ cookies }) => { const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); - cookies.delete('operatorId', { path: '/' }); + await deleteOperatorSessions(operatorId); + cookies.delete('operatorSession', { path: '/' }); return new Response(buffer, { headers: { diff --git a/src/routes/api/shifts/end/+server.ts b/src/routes/api/shifts/end/+server.ts index c9eef29..b63f103 100644 --- a/src/routes/api/shifts/end/+server.ts +++ b/src/routes/api/shifts/end/+server.ts @@ -7,7 +7,7 @@ import { getVerifiedOperatorId } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, cookies }) => { - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } @@ -15,7 +15,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { - cookies.delete('operatorId', { path: '/' }); + cookies.delete('operatorSession', { path: '/' }); return json({ error: 'Invalid session' }, { status: 401 }); } diff --git a/src/routes/api/shifts/logout/+server.ts b/src/routes/api/shifts/logout/+server.ts index 03985a0..c2e50a5 100644 --- a/src/routes/api/shifts/logout/+server.ts +++ b/src/routes/api/shifts/logout/+server.ts @@ -1,7 +1,12 @@ import { json } from '@sveltejs/kit'; +import { getVerifiedOperatorId, deleteOperatorSessions } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ cookies }) => { - cookies.delete('operatorId', { path: '/' }); + const operatorId = await getVerifiedOperatorId(cookies); + if (operatorId) { + await deleteOperatorSessions(operatorId); + } + cookies.delete('operatorSession', { path: '/' }); return json({ success: true }); }; diff --git a/src/routes/api/shifts/start/+server.ts b/src/routes/api/shifts/start/+server.ts index 4815753..127203d 100644 --- a/src/routes/api/shifts/start/+server.ts +++ b/src/routes/api/shifts/start/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { operators, shifts } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; -import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, signCookieValue } from '$lib/server/auth'; +import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, createOperatorSession } from '$lib/server/auth'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; @@ -56,7 +56,8 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress shift = newShift; } - cookies.set('operatorId', signCookieValue(String(operatorId)), { + const sessionToken = await createOperatorSession(operatorId); + cookies.set('operatorSession', sessionToken, { path: '/', httpOnly: true, sameSite: 'lax', diff --git a/src/routes/api/store-sales/+server.ts b/src/routes/api/store-sales/+server.ts index 1ff6e35..1a84fa9 100644 --- a/src/routes/api/store-sales/+server.ts +++ b/src/routes/api/store-sales/+server.ts @@ -6,7 +6,7 @@ import { getVerifiedOperatorId } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, cookies }) => { - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } @@ -14,7 +14,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { - cookies.delete('operatorId', { path: '/' }); + cookies.delete('operatorSession', { path: '/' }); return json({ error: 'Invalid session' }, { status: 401 }); } @@ -87,7 +87,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; export const GET: RequestHandler = async ({ url, cookies }) => { - const operatorId = getVerifiedOperatorId(cookies); + const operatorId = await getVerifiedOperatorId(cookies); if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } @@ -95,7 +95,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { - cookies.delete('operatorId', { path: '/' }); + cookies.delete('operatorSession', { path: '/' }); return json({ error: 'Invalid session' }, { status: 401 }); } From 3fead8998ba8f0461a1f99cba8750d2bf8d649f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 20:51:03 +0000 Subject: [PATCH 2/2] Update package-lock.json https://claude.ai/code/session_01Rf47gNAgM7jDstC4bvffzw --- package-lock.json | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3bccc8..b7ed817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1454,7 +1454,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz", "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1493,7 +1492,6 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1569,7 +1567,6 @@ "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1712,7 +1709,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2766,7 +2762,6 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3656,7 +3651,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -3775,7 +3769,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4172,7 +4165,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4294,7 +4286,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4321,7 +4312,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0",