import { os } from "@orpc/server"; import { z } from "zod"; import { randomUUID } from "crypto"; import { config } from "dotenv"; import bcrypt from "bcrypt"; import { setCookie } from "hono/cookie"; import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js"; import { generateCSRFToken, getSessionFromCookies, validateCSRFToken, rotateSession, COOKIE_OPTIONS, SESSION_COOKIE_NAME, CSRF_COOKIE_NAME, sessionsKV, usersKV } from "../lib/auth.js"; // Load environment variables from .env file config(); const UserSchema = z.object({ id: z.string(), username: z.string().min(3, "Benutzername muss mindestens 3 Zeichen lang sein"), email: z.string().email("Ungültige E-Mail-Adresse"), passwordHash: z.string(), role: z.enum(["customer", "owner"]), createdAt: z.string(), }); const SessionSchema = z.object({ id: z.string(), userId: z.string(), expiresAt: z.string(), createdAt: z.string(), csrfToken: z.string().optional(), }); // Use shared KV stores from auth.ts to avoid duplication // Password hashing using bcrypt const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y const isBase64Hash = (hash) => { if (hash.startsWith(BCRYPT_PREFIX)) return false; try { const decoded = Buffer.from(hash, 'base64'); // If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64 const reencoded = decoded.toString('base64'); // Additionally ensure that decoding does not produce too short/empty unless original was empty return reencoded === hash && decoded.toString('utf8').length > 0; } catch { return false; } }; const hashPassword = async (password) => { return bcrypt.hash(password, 10); }; const verifyPassword = async (password, hash) => { if (hash.startsWith(BCRYPT_PREFIX)) { return bcrypt.compare(password, hash); } if (isBase64Hash(hash)) { const base64OfPassword = Buffer.from(password).toString('base64'); return base64OfPassword === hash; } // Unknown format -> fail closed return false; }; // Export hashPassword for external use (e.g., generating hashes for .env) export const generatePasswordHash = hashPassword; // Migrate all legacy Base64 password hashes to bcrypt on server startup const migrateLegacyHashesOnStartup = async () => { const users = await usersKV.getAllItems(); let migratedCount = 0; for (const user of users) { if (isBase64Hash(user.passwordHash)) { try { const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8'); const bcryptHash = await hashPassword(plaintext); const updatedUser = { ...user, passwordHash: bcryptHash }; await usersKV.setItem(user.id, updatedUser); migratedCount += 1; } catch { // ignore individual failures; continue with others } } } if (migratedCount > 0) { console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`); } }; // Initialize default owner account const initializeOwner = async () => { const existingUsers = await usersKV.getAllItems(); if (existingUsers.length === 0) { const ownerId = randomUUID(); // Get admin credentials from environment variables const adminUsername = process.env.ADMIN_USERNAME || "owner"; let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123"); // If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) { const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8'); adminPasswordHash = await hashPassword(plaintext); } const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de"; const owner = { id: ownerId, username: adminUsername, email: adminEmail, passwordHash: adminPasswordHash, role: "owner", createdAt: new Date().toISOString(), }; await usersKV.setItem(ownerId, owner); console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`); } }; // Initialize on module load: first migrate legacy hashes, then ensure owner exists (async () => { try { await migrateLegacyHashesOnStartup(); } finally { await initializeOwner(); } })(); const login = os .input(z.object({ username: z.string(), password: z.string(), })) .handler(async ({ input, context }) => { const ip = getClientIP(context.req.raw.headers); const users = await usersKV.getAllItems(); const user = users.find(u => u.username === input.username); if (!user) { const rl = checkLoginRateLimit(ip); if (!rl.allowed) { throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`); } throw new Error("Invalid credentials"); } const isValid = await verifyPassword(input.password, user.passwordHash); if (!isValid) { const rl = checkLoginRateLimit(ip); if (!rl.allowed) { throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`); } throw new Error("Invalid credentials"); } // Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt if (isBase64Hash(user.passwordHash)) { const migratedHash = await hashPassword(input.password); const migratedUser = { ...user, passwordHash: migratedHash }; await usersKV.setItem(user.id, migratedUser); } // Create session with CSRF token const sessionId = randomUUID(); const csrfToken = generateCSRFToken(); const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours const session = { id: sessionId, userId: user.id, expiresAt: expiresAt.toISOString(), createdAt: new Date().toISOString(), csrfToken, }; await sessionsKV.setItem(sessionId, session); // Optional: Reset login attempts on successful login resetLoginRateLimit(ip); // Set cookies in response setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS); setCookie(context, CSRF_COOKIE_NAME, csrfToken, { ...COOKIE_OPTIONS, httpOnly: false, // CSRF token needs to be readable by JavaScript }); // Return only user object (no sessionId in response) return { user: { id: user.id, username: user.username, email: user.email, role: user.role, }, }; }); const logout = os .input(z.object({})) // No input needed - session comes from cookies .handler(async ({ context }) => { const session = await getSessionFromCookies(context); if (session) { await sessionsKV.removeItem(session.id); } // Clear both cookies with correct options setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 }); setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 }); return { success: true }; }); const verifySession = os .input(z.object({})) // No input needed - session comes from cookies .handler(async ({ context }) => { const session = await getSessionFromCookies(context); if (!session) { throw new Error("Invalid session"); } const user = await usersKV.getItem(session.userId); if (!user) { throw new Error("User not found"); } return { user: { id: user.id, username: user.username, email: user.email, role: user.role, }, }; }); const changePassword = os .input(z.object({ currentPassword: z.string(), newPassword: z.string(), })) .handler(async ({ input, context }) => { const session = await getSessionFromCookies(context); if (!session) { throw new Error("Invalid session"); } const user = await usersKV.getItem(session.userId); if (!user) { throw new Error("User not found"); } // Validate CSRF token for password change await validateCSRFToken(context, session.id); const currentOk = await verifyPassword(input.currentPassword, user.passwordHash); if (!currentOk) { throw new Error("Current password is incorrect"); } const updatedUser = { ...user, passwordHash: await hashPassword(input.newPassword), }; await usersKV.setItem(user.id, updatedUser); // Implement session rotation after password change const newSession = await rotateSession(session.id, user.id); // Set new session and CSRF cookies setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS); setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken, { ...COOKIE_OPTIONS, httpOnly: false, }); return { success: true }; }); export const router = { login, logout, verifySession, changePassword, };