From 50e2b3297e2b6f7d2e15f11f2fb511fa39c5bb28 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 26 Feb 2026 16:08:00 -0800 Subject: client-side password auth --- index.ts | 1 + lib/register.ts | 2 +- routes/users.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index efcaf484..634843a5 100644 --- a/index.ts +++ b/index.ts @@ -173,6 +173,7 @@ app.post( ); app.post("/flash", users.flash); app.get("/challenge", users.challenge); +app.post("/authKeyLogin", users.authKeyLogin); app.post("/nostrAuth", users.nostrAuth); app.post("/passkey/register/options", auth, users.passkeyRegisterOptions); app.post("/passkey/register/verify", auth, users.passkeyRegisterVerify); diff --git a/lib/register.ts b/lib/register.ts index ac1997db..791f16a5 100644 --- a/lib/register.ts +++ b/lib/register.ts @@ -13,7 +13,7 @@ import { v4 } from "uuid"; const valid = /^[\p{L}\p{N}]{2,24}$/u; export default async (user, ip) => { - let { password, pubkey, username } = user; + let { password, pubkey, username, authPubkey } = user; l("registering", username); const reserved = ["ecash"]; diff --git a/routes/users.ts b/routes/users.ts index 19a1b206..56452918 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -176,7 +176,7 @@ export default { if (!body.user) fail("no user object provided"); let { user } = body; - const fields = ["pubkey", "password", "username", "picture", "fresh"]; + const fields = ["pubkey", "password", "username", "picture", "fresh", "authPubkey"]; user = await register(pick(user, fields), ip); const payload = { id: user.id }; @@ -348,6 +348,7 @@ export default { algorithm: "bcrypt", cost: 4, }); + if (body.authPubkey) user.authPubkey = body.authPubkey; } user.haspin = !!user.pin; @@ -412,6 +413,11 @@ export default { } } + if (req.body.authPubkey && !user.authPubkey) { + user.authPubkey = req.body.authPubkey; + await s(`user:${user.id}`, user); + } + if (username !== "coinos") l("logged in", username, req.headers["cf-connecting-ip"]); const payload = { id: user.id }; @@ -426,10 +432,65 @@ export default { } }, - async challenge(_, res) { + async challenge(req, res) { const id = v4(); await db.set(`challenge:${id}`, id, { EX: 300 }); - res.send({ challenge: id }); + const username = req.query?.username; + let hasAuthKey = false; + if (username) { + const user = await getUser(username.toLowerCase().replace(/\s/g, "")); + if (user?.authPubkey) hasAuthKey = true; + } + res.send({ challenge: id, hasAuthKey }); + }, + + async authKeyLogin(req, res) { + try { + const { event, challenge, username: rawUsername, twofa, recaptcha } = req.body; + const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress; + + const ipKey = `ip:${ip}:login`; + const ipCount = await db.incr(ipKey); + if (ipCount === 1) await db.expire(ipKey, 10); + if (ipCount > 30) return res.code(429).send({}); + + const recaptchaOk = await verifyRecaptcha(recaptcha, req); + if (!recaptchaOk) return res.code(401).send("failed captcha"); + + const c = await g(`challenge:${challenge}`); + if (!c) fail("Invalid or expired challenge"); + + const { kind } = event; + if (kind !== 27235) fail("Invalid event"); + + if (!verifyEvent(event) || event.tags.find((t) => t[0] === "challenge")?.[1] !== challenge) + fail("Invalid signature or challenge mismatch."); + + const username = rawUsername.toLowerCase().replace(/\s/g, ""); + let user = await getUser(username); + if (!user) fail("User not found"); + if (!user.authPubkey || user.authPubkey !== event.pubkey) + fail("Auth key mismatch"); + + if ( + user.twofa && + (typeof twofa === "undefined" || !authenticator.check(twofa, user.otpsecret)) + ) { + return res.code(401).send("2fa required"); + } + + l("authkey login", username, ip); + + const payload = { id: user.id }; + const token = jwt.sign(payload, config.jwt); + res.cookie("token", token, { expires: new Date(Date.now() + 432000000) }); + user = pick(user, whitelist); + res.send({ user, token }); + } catch (e) { + console.log(e); + err("authkey login error", e.message, req.socket.remoteAddress); + res.code(401).send({}); + } }, async nostrAuth(req, res) { @@ -646,6 +707,7 @@ export default { user.pin = null; user.nsec = null; + user.authPubkey = null; user.password = await Bun.password.hash(password, { algorithm: "bcrypt", -- cgit v1.2.3