chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
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({
|
||||
@@ -18,18 +21,63 @@ const SessionSchema = z.object({
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
csrfToken: z.string().optional(),
|
||||
});
|
||||
const usersKV = createKV("users");
|
||||
const sessionsKV = createKV("sessions");
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
const hashPassword = (password) => {
|
||||
return Buffer.from(password).toString('base64');
|
||||
// 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 verifyPassword = (password, hash) => {
|
||||
return hashPassword(password) === hash;
|
||||
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();
|
||||
@@ -37,7 +85,12 @@ const initializeOwner = async () => {
|
||||
const ownerId = randomUUID();
|
||||
// Get admin credentials from environment variables
|
||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||
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,
|
||||
@@ -51,21 +104,48 @@ const initializeOwner = async () => {
|
||||
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||
}
|
||||
};
|
||||
// Initialize on module load
|
||||
initializeOwner();
|
||||
// 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 }) => {
|
||||
.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 || !verifyPassword(input.password, user.passwordHash)) {
|
||||
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");
|
||||
}
|
||||
// Create session
|
||||
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 = {
|
||||
@@ -73,10 +153,19 @@ const login = os
|
||||
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 {
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -86,22 +175,24 @@ const login = os
|
||||
};
|
||||
});
|
||||
const logout = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
await sessionsKV.removeItem(input);
|
||||
.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.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input);
|
||||
.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");
|
||||
}
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
await sessionsKV.removeItem(input);
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -117,12 +208,11 @@ const verifySession = os
|
||||
});
|
||||
const changePassword = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
@@ -130,14 +220,25 @@ const changePassword = os
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
|
||||
// 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: hashPassword(input.newPassword),
|
||||
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 = {
|
||||
|
Reference in New Issue
Block a user