update
This commit is contained in:
@@ -43,6 +43,16 @@ import {
|
||||
updateUser,
|
||||
} from "./db.ts";
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import {
|
||||
consumeTotpChallenge,
|
||||
createTotpChallenge,
|
||||
getTotpSecret,
|
||||
initTotpTables,
|
||||
newTotpSecret,
|
||||
setTotpSecret,
|
||||
totpAuthUrl,
|
||||
verifyTotp,
|
||||
} from "./totp.ts";
|
||||
import {
|
||||
addAccessMessage,
|
||||
createAccessRequest,
|
||||
@@ -83,6 +93,7 @@ const registry = new DriverRegistry();
|
||||
const ollamaDriver = registry.get("ollama") as OllamaDriver;
|
||||
await tools.init();
|
||||
initAccessTables();
|
||||
initTotpTables();
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
@@ -297,10 +308,35 @@ const server = Bun.serve({
|
||||
return json({ error: "invalid credentials" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
// Second factor: correct key but TOTP enabled -> short-lived challenge,
|
||||
// the session is only issued after POST /api/auth/totp verifies a code.
|
||||
if (getTotpSecret(user.id)) {
|
||||
const totpToken = createTotpChallenge(user.id);
|
||||
return json({ totpRequired: true, totpToken, username: user.username });
|
||||
}
|
||||
const { token, expiresAt } = issueToken(user);
|
||||
return json({ token, expiresAt, user });
|
||||
}
|
||||
|
||||
// --- auth: TOTP second step (rate limited, challenge is single-use) ---
|
||||
if (path === "/api/auth/totp" && req.method === "POST") {
|
||||
const ip = clientIp(req, server);
|
||||
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
|
||||
const { ok, body } = await readJson(req);
|
||||
const userId =
|
||||
ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null;
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
const target = userId ? getUserById(userId) : null;
|
||||
if (!target || !verifyTotp(getTotpSecret(target.id), code)) {
|
||||
recordAttempt(ip);
|
||||
await Bun.sleep(400);
|
||||
return json({ error: "invalid code" }, 401);
|
||||
}
|
||||
clearAttempts(ip);
|
||||
const { token, expiresAt } = issueToken(toPublicUser(target));
|
||||
return json({ token, expiresAt, user: toPublicUser(target) });
|
||||
}
|
||||
|
||||
if (path === "/api/auth/verify" && req.method === "GET") {
|
||||
const user = requireAuth(req);
|
||||
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
|
||||
@@ -506,6 +542,48 @@ const server = Bun.serve({
|
||||
return json({ user: toPublicUser(updated) });
|
||||
}
|
||||
|
||||
// --- self-service security: rotate access key (old sessions revoked) ---
|
||||
if (path === "/api/me/key/rotate" && req.method === "POST") {
|
||||
const key = newAccessKey();
|
||||
updateUser(user.id, { keyHash: hashSecret(key) });
|
||||
deleteUserSessions(user.id);
|
||||
return json({ key });
|
||||
}
|
||||
|
||||
// --- self-service: delete your own account (chats, shares, sessions gone) ---
|
||||
if (path === "/api/me" && req.method === "DELETE") {
|
||||
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
|
||||
deleteUser(user.id);
|
||||
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// --- self-service security: TOTP two-factor ---
|
||||
if (path === "/api/me/totp" && req.method === "GET") {
|
||||
return json({ enabled: getTotpSecret(user.id).length > 0 });
|
||||
}
|
||||
if (path === "/api/me/totp/setup" && req.method === "POST") {
|
||||
if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
|
||||
const secret = newTotpSecret();
|
||||
return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) });
|
||||
}
|
||||
if (path === "/api/me/totp/verify" && req.method === "POST") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const secret = ok && typeof body.secret === "string" ? body.secret : "";
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400);
|
||||
if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401);
|
||||
setTotpSecret(user.id, secret.trim().toUpperCase());
|
||||
return json({ enabled: true });
|
||||
}
|
||||
if (path === "/api/me/totp" && req.method === "DELETE") {
|
||||
const { ok, body } = await readJson(req);
|
||||
const code = ok && typeof body.code === "string" ? body.code : "";
|
||||
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
|
||||
setTotpSecret(user.id, "");
|
||||
return json({ enabled: false });
|
||||
}
|
||||
|
||||
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
|
||||
if (path === "/api/admin/users" && req.method === "GET") {
|
||||
if (!admin) return json({ error: "forbidden" }, 403);
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { getDb } from "./db.ts";
|
||||
|
||||
/**
|
||||
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
|
||||
* The per-user secret lives in `users.totp_secret` (empty = disabled);
|
||||
* login challenges live in `totp_challenges`. Tables/columns are created
|
||||
* idempotently here so db.ts stays untouched.
|
||||
*/
|
||||
|
||||
const STEP_SEC = 30;
|
||||
const DIGITS = 6;
|
||||
const WINDOW = 1;
|
||||
const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export function initTotpTables(): void {
|
||||
const d = getDb();
|
||||
try {
|
||||
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
|
||||
} catch {
|
||||
// column already exists — fine
|
||||
}
|
||||
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`).run();
|
||||
}
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function newTotpSecret(bytes = 20): string {
|
||||
const buf = randomBytes(bytes);
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const b of buf) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += B32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string {
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
function base32Decode(s: string): Buffer {
|
||||
const clean = s.trim().replace(/=+$/, "").toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = B32.indexOf(ch);
|
||||
if (idx < 0) throw new Error("invalid base32");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function hotp(key: Buffer, counter: bigint): string {
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(counter);
|
||||
const h = createHmac("sha1", key).update(msg).digest();
|
||||
const offset = h[h.length - 1]! & 0x0f;
|
||||
const code =
|
||||
((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!));
|
||||
return String(code % 10 ** DIGITS).padStart(DIGITS, "0");
|
||||
}
|
||||
|
||||
/** Timing-safe comparison against the current ±1 window. */
|
||||
export function verifyTotp(secret: string, code: string): boolean {
|
||||
const c = code.trim();
|
||||
if (!/^\d{6}$/.test(c)) return false;
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = base32Decode(secret);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (key.length < 10) return false;
|
||||
const now = Math.floor(Date.now() / 1000 / STEP_SEC);
|
||||
let ok = false;
|
||||
for (let w = -WINDOW; w <= WINDOW; w++) {
|
||||
const expected = hotp(key, BigInt(now + w));
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(c);
|
||||
if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
// Constant-time-ish: always hash-compare all window slots (loop above).
|
||||
return ok;
|
||||
}
|
||||
|
||||
export function getTotpSecret(userId: string): string {
|
||||
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
|
||||
return row?.s ?? "";
|
||||
}
|
||||
|
||||
export function setTotpSecret(userId: string, secret: string): void {
|
||||
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
|
||||
}
|
||||
|
||||
/** Short-lived login challenge after a correct key; returns the raw token. */
|
||||
export function createTotpChallenge(userId: string): string {
|
||||
const token = "tc_" + randomBytes(24).toString("base64url");
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
const now = Date.now();
|
||||
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
|
||||
hash, userId, now, now + CHALLENGE_TTL_MS,
|
||||
);
|
||||
// Opportunistic cleanup.
|
||||
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function consumeTotpChallenge(token: string): string | null {
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
|
||||
user_id: string;
|
||||
expires_at: number;
|
||||
} | null;
|
||||
if (!row) return null;
|
||||
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
|
||||
if (row.expires_at < Date.now()) return null;
|
||||
return row.user_id;
|
||||
}
|
||||
Reference in New Issue
Block a user