From 19ae212851155ba44355e68ec55579ca06919ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 21:26:17 +0000 Subject: [PATCH] Allow login with plaintext passcodes and auto-rehash to bcrypt Existing operators/guides had plaintext passcodes in the DB which verifyPasscode rejected. Now plaintext is accepted and automatically rehashed to bcrypt on successful login (fire-and-forget, all endpoints). https://claude.ai/code/session_01Rf47gNAgM7jDstC4bvffzw --- src/lib/server/auth.ts | 28 ++++++++++++++++++++-- src/routes/api/admin/login/+server.ts | 3 ++- src/routes/api/guides/verify/+server.ts | 3 ++- src/routes/api/operators/verify/+server.ts | 3 ++- src/routes/api/shifts/start/+server.ts | 3 ++- 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 865abde..626e409 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'; import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; import { logger } from '$lib/server/logger'; import { db } from '$lib/server/db'; -import { rateLimits, operatorSessions } from '$lib/server/db/schema'; +import { rateLimits, operatorSessions, operators, guides } from '$lib/server/db/schema'; import { eq, lt } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; import type { Cookies } from '@sveltejs/kit'; @@ -28,10 +28,34 @@ export async function verifyPasscode(passcode: string, stored: string): Promise< if (stored.startsWith('$2a$') || stored.startsWith('$2b$')) { return bcrypt.compare(passcode, stored); } - logger.warn('Rejected login attempt against unhashed passcode'); + // Plaintext passcode — compare directly (legacy migration path) + if (passcode === stored) { + logger.warn('Matched plaintext passcode — will be rehashed on next login'); + return true; + } return false; } +/** + * Rehash a plaintext passcode in the DB. Call after successful verifyPasscode. + */ +export async function rehashIfPlaintext( + table: 'operators' | 'guides', + id: number, + stored: string +): Promise { + if (stored.startsWith(HASH_PREFIX) || stored.startsWith('$2a$') || stored.startsWith('$2b$')) { + return; // Already hashed + } + const hashed = await hashPasscode(stored); + if (table === 'operators') { + await db.update(operators).set({ passcode: hashed }).where(eq(operators.id, id)); + } else { + await db.update(guides).set({ passcode: hashed }).where(eq(guides.id, id)); + } + logger.info({ table, id }, 'Rehashed plaintext passcode'); +} + export function logAuthFailure(endpoint: string, identifier: string, ip: string): void { const maskedIp = ip.includes('.') ? ip.replace(/\.\d+$/, '.***') : ip.replace(/:[^:]+$/, ':***'); logger.warn({ endpoint, identifier, ip: maskedIp }, 'auth_failure'); diff --git a/src/routes/api/admin/login/+server.ts b/src/routes/api/admin/login/+server.ts index bef358c..d51d426 100644 --- a/src/routes/api/admin/login/+server.ts +++ b/src/routes/api/admin/login/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { operators, adminSessions } from '$lib/server/db/schema'; import { eq, and, lt } from 'drizzle-orm'; -import { verifyPasscode, generateSessionToken, checkRateLimit, clearRateLimit } from '$lib/server/auth'; +import { verifyPasscode, generateSessionToken, checkRateLimit, clearRateLimit, rehashIfPlaintext } from '$lib/server/auth'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; @@ -49,6 +49,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress } await clearRateLimit(`admin-login:${clientIp}`); + rehashIfPlaintext('operators', operator.id, operator.passcode).catch(() => {}); const token = generateSessionToken(); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE * 1000); diff --git a/src/routes/api/guides/verify/+server.ts b/src/routes/api/guides/verify/+server.ts index 5991955..7deb853 100644 --- a/src/routes/api/guides/verify/+server.ts +++ b/src/routes/api/guides/verify/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { guides } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; -import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure } from '$lib/server/auth'; +import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, rehashIfPlaintext } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, getClientAddress }) => { @@ -35,6 +35,7 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { } await clearRateLimit(`guide-verify:${clientIp}`); + rehashIfPlaintext('guides', guideId, guide.passcode).catch(() => {}); return json({ success: true, guide }); }; diff --git a/src/routes/api/operators/verify/+server.ts b/src/routes/api/operators/verify/+server.ts index 3544894..f12e56a 100644 --- a/src/routes/api/operators/verify/+server.ts +++ b/src/routes/api/operators/verify/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { operators } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; -import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure } from '$lib/server/auth'; +import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, rehashIfPlaintext } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, getClientAddress }) => { @@ -45,6 +45,7 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { } await clearRateLimit(`operator-verify:${clientIp}`); + rehashIfPlaintext('operators', operatorId, operator.passcode).catch(() => {}); return json({ success: true }); }; diff --git a/src/routes/api/shifts/start/+server.ts b/src/routes/api/shifts/start/+server.ts index 127203d..a22aba7 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, createOperatorSession } from '$lib/server/auth'; +import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, createOperatorSession, rehashIfPlaintext } from '$lib/server/auth'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; @@ -39,6 +39,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress } await clearRateLimit(`shift-start:${clientIp}`); + rehashIfPlaintext('operators', operatorId, operator.passcode).catch(() => {}); const [existingShift] = await db .select()