fix(database): use per-user admin passwords
This commit is contained in:
1 parent
33e80a566e
commit
9862348d70
17 files changed
+188
-38
No files matched your search
+60
-14
@@ -7,15 +7,24 @@ const TABLES = {
|
||||
};
|
||||
const META = new Set(["profile", "settings", "system"]);
|
||||
const DURATION = 20 * 60 * 1000;
|
||||
const ITERATIONS = 210_000;
|
||||
const body = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } });
|
||||
const validId = value => typeof value === "string" && value.length > 0 && value.length <= 200;
|
||||
const hash = async value => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
const equal = async (a, b) => {
|
||||
const [first, second] = await Promise.all([hash(a), hash(b)]);
|
||||
const secureEqual = (first, second) => {
|
||||
if (typeof first !== "string" || typeof second !== "string" || first.length !== second.length) return false;
|
||||
let difference = 0;
|
||||
for (let i = 0; i < first.length; i++) difference |= first.charCodeAt(i) ^ second.charCodeAt(i);
|
||||
return difference === 0;
|
||||
};
|
||||
const hex = bytes => [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
const unhex = value => Uint8Array.from(value.match(/../g).map(pair => parseInt(pair, 16)));
|
||||
async function derive(password, salt, iterations = ITERATIONS) {
|
||||
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
|
||||
return hex(new Uint8Array(await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: unhex(salt), iterations }, key, 256)));
|
||||
}
|
||||
const validPassword = value => typeof value === "string" && value.length >= 12 && value.length <= 256;
|
||||
const salt = () => hex(crypto.getRandomValues(new Uint8Array(16)));
|
||||
const token = () => {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
@@ -23,34 +32,65 @@ const token = () => {
|
||||
async function signedIn(request, db, userId) {
|
||||
const raw = request.headers.get("x-lifeos-admin-session");
|
||||
if (!raw || raw.length > 100) return false;
|
||||
const record = await db.prepare("SELECT expires_at FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(raw), userId).first();
|
||||
const record = await db.prepare("SELECT s.expires_at FROM lifeos_admin_sessions s JOIN lifeos_admin_credentials c ON c.user_id = s.user_id WHERE s.token_hash = ? AND s.user_id = ?").bind(await hash(raw), userId).first();
|
||||
return !!record && record.expires_at > Date.now();
|
||||
}
|
||||
function originAllowed(request) {
|
||||
const origin = request.headers.get("origin");
|
||||
return !origin || origin === new URL(request.url).origin;
|
||||
}
|
||||
async function readInput(request) {
|
||||
const text = await request.text();
|
||||
if (text.length > 2048) return null;
|
||||
try { return JSON.parse(text); } catch { return null; }
|
||||
}
|
||||
async function newSession(db, userId) {
|
||||
const raw = token(), expiresAt = Date.now() + DURATION;
|
||||
await db.prepare("INSERT INTO lifeos_admin_sessions(token_hash, user_id, expires_at) VALUES (?, ?, ?)").bind(await hash(raw), userId, expiresAt).run();
|
||||
return body({ token: raw, expiresAt });
|
||||
}
|
||||
async function setup(request, db, userId) {
|
||||
const input = await readInput(request);
|
||||
if (input?.acknowledged !== true || input.username !== "lifeos-admin" || !validPassword(input.password)) return body({ error: "Invalid setup" }, 400);
|
||||
const newSalt = salt(), passwordHash = await derive(input.password, newSalt);
|
||||
const result = await db.prepare("INSERT INTO lifeos_admin_credentials(user_id, salt, password_hash, iterations, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING")
|
||||
.bind(userId, newSalt, passwordHash, ITERATIONS, new Date().toISOString()).run();
|
||||
if (!result.meta.changes) return body({ error: "Password already configured" }, 409);
|
||||
await db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId).run();
|
||||
await db.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run();
|
||||
return newSession(db, userId);
|
||||
}
|
||||
async function login(request, env, userId) {
|
||||
const secret = env.LIFEOS_DB_ADMIN_PASSWORD;
|
||||
if (typeof secret !== "string" || secret.length < 20) return body({ error: "Admin login is not configured" }, 503);
|
||||
const credential = await env.DB.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first();
|
||||
if (!credential) return body({ error: "Set your password first" }, 409);
|
||||
const row = await env.DB.prepare("SELECT failures, locked_until FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).first();
|
||||
if (row?.locked_until > Date.now()) return body({ error: "Too many attempts", retryAt: row.locked_until }, 429);
|
||||
const text = await request.text();
|
||||
if (text.length > 1024) return body({ error: "Invalid credentials" }, 400);
|
||||
let input;
|
||||
try { input = JSON.parse(text); } catch { return body({ error: "Invalid JSON" }, 400); }
|
||||
const input = await readInput(request);
|
||||
if (input?.acknowledged !== true) return body({ error: "Direct database access must be acknowledged" }, 400);
|
||||
const matched = input.username === "lifeos-admin" && typeof input.password === "string" &&
|
||||
await equal(input.password, secret);
|
||||
const matched = input.username === "lifeos-admin" && validPassword(input.password) &&
|
||||
secureEqual(await derive(input.password, credential.salt, credential.iterations), credential.password_hash);
|
||||
if (!matched) {
|
||||
const failures = (row?.failures || 0) + 1, until = failures >= 5 ? Date.now() + 15 * 60_000 : 0;
|
||||
await env.DB.prepare("INSERT INTO lifeos_admin_attempts(user_id, failures, locked_until) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET failures = excluded.failures, locked_until = excluded.locked_until").bind(userId, failures >= 5 ? 0 : failures, until).run();
|
||||
return body({ error: "Invalid credentials", retryAt: until || null }, 401);
|
||||
}
|
||||
await env.DB.prepare("DELETE FROM lifeos_admin_attempts WHERE user_id = ?").bind(userId).run();
|
||||
const raw = token(), expiresAt = Date.now() + DURATION;
|
||||
await env.DB.prepare("INSERT INTO lifeos_admin_sessions(token_hash, user_id, expires_at) VALUES (?, ?, ?)").bind(await hash(raw), userId, expiresAt).run();
|
||||
return body({ token: raw, expiresAt });
|
||||
return newSession(env.DB, userId);
|
||||
}
|
||||
async function changePassword(request, db, userId) {
|
||||
const input = await readInput(request);
|
||||
if (!validPassword(input?.currentPassword) || !validPassword(input?.newPassword)) return body({ error: "Invalid password" }, 400);
|
||||
const credential = await db.prepare("SELECT salt, password_hash, iterations FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first();
|
||||
if (!credential || !secureEqual(await derive(input.currentPassword, credential.salt, credential.iterations), credential.password_hash))
|
||||
return body({ error: "Current password is incorrect" }, 401);
|
||||
if (input.newPassword === input.currentPassword) return body({ error: "Choose a different password" }, 400);
|
||||
const newSalt = salt(), passwordHash = await derive(input.newPassword, newSalt);
|
||||
await db.batch([
|
||||
db.prepare("UPDATE lifeos_admin_credentials SET salt = ?, password_hash = ?, iterations = ?, updated_at = ? WHERE user_id = ?")
|
||||
.bind(newSalt, passwordHash, ITERATIONS, new Date().toISOString(), userId),
|
||||
db.prepare("DELETE FROM lifeos_admin_sessions WHERE user_id = ?").bind(userId)
|
||||
]);
|
||||
return newSession(db, userId);
|
||||
}
|
||||
async function list(db, userId, kind, page) {
|
||||
const table = TABLES[kind], offset = page * 50;
|
||||
@@ -92,8 +132,14 @@ export async function dbAdminApi(request, env, userId, pathname) {
|
||||
if (!originAllowed(request)) return body({ error: "Origin rejected" }, 403);
|
||||
const segments = pathname.slice("/api/db-admin".length).split("/").filter(Boolean);
|
||||
try {
|
||||
if (segments[0] === "config" && segments.length === 1 && request.method === "GET") {
|
||||
const credential = await env.DB.prepare("SELECT user_id FROM lifeos_admin_credentials WHERE user_id = ?").bind(userId).first();
|
||||
return body({ configured: !!credential });
|
||||
}
|
||||
if (segments[0] === "setup" && segments.length === 1 && request.method === "POST") return setup(request, env.DB, userId);
|
||||
if (segments[0] === "login" && segments.length === 1 && request.method === "POST") return login(request, env, userId);
|
||||
if (!await signedIn(request, env.DB, userId)) return body({ error: "Admin session required" }, 401);
|
||||
if (segments[0] === "password" && segments.length === 1 && request.method === "POST") return changePassword(request, env.DB, userId);
|
||||
if (segments[0] === "session" && segments.length === 1 && request.method === "GET") return body({ ok: true });
|
||||
if (segments[0] === "logout" && segments.length === 1 && request.method === "POST") {
|
||||
await env.DB.prepare("DELETE FROM lifeos_admin_sessions WHERE token_hash = ? AND user_id = ?").bind(await hash(request.headers.get("x-lifeos-admin-session")), userId).run();
|
||||
|
||||
Reference in new issue
Block a user