Files
beauty-bookings/server-dist/rpc/auth.js

250 lines
9.1 KiB
JavaScript

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,
};