aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-26 16:08:00 -0800
committerAdam Soltys <[email protected]>2026-02-26 16:08:00 -0800
commit50e2b3297e2b6f7d2e15f11f2fb511fa39c5bb28 (patch)
treedf8169460317f352ebefcbe958c405b7213effca
parent71fde8aa24b2e976b450fa16e3debe1078433086 (diff)
downloadcoinos-server-50e2b3297e2b6f7d2e15f11f2fb511fa39c5bb28.tar.gz
coinos-server-50e2b3297e2b6f7d2e15f11f2fb511fa39c5bb28.zip
client-side password auth
-rw-r--r--index.ts1
-rw-r--r--lib/register.ts2
-rw-r--r--routes/users.ts68
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",