import { APIUtils, getSrcIpDefault, NoBody } from "../apiutils.ts"; // @ts-types = "npm:@types/express" import express from "express"; import { User, UserTokenFormat } from "../data/users.ts"; import { Config } from "../config.ts"; import crypto from "node:crypto"; import Logging from "@proxnet/undead-logging"; import { decode } from "@gz/jwt"; import z from "zod"; const log = new Logging("UserRoute"); const config = Config.getConfig(); export const route = APIUtils.createRouter("/user"); interface AuthRequestSec { timestamp: number; nonce: string; server_id: string; } interface AuthRequestRoot { client_id: string; message: AuthRequestSec; signature: string; pubkey: string; } const AuthRequestSecSchema = z.object({ timestamp: z.number(), nonce: z.string(), server_id: z.string(), }); const AuthRequestRootSchema = z.object({ client_id: z.string(), message: AuthRequestSecSchema, signature: z.string(), pubkey: z.string(), }); const rateLimit = new APIUtils.RateLimiter(60, 1); route.router.post("/auth", rateLimit.middle(), express.json(), APIUtils.validateRequestBody(AuthRequestRootSchema), async ( rq: express.Request, rs: express.Response, ) => { function authFailed(msg: string) { rs.json(APIUtils.genericResponseFormat(true, msg)); } if (rq.body.message.server_id !== config.public.serverId) { log.w( `Auth request failed (serverId mismatch), config error?\n given ID: '${rq.body.message.server_id}'\n our ID: '${config.public.serverId}'`, ); authFailed("Authentication request not intended for this server."); return; } try { const verify = crypto.createVerify("SHA256"); verify.update(JSON.stringify(rq.body.message)); verify.end(); const publicKey = await crypto.subtle.importKey( "spki", (Uint8Array.from(atob(rq.body.pubkey), (c) => c.charCodeAt(0))) .buffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"], ); const messageBytes = new TextEncoder().encode( JSON.stringify(rq.body.message), ); const signatureBytes = Uint8Array.from( atob(rq.body.signature), (c) => c.charCodeAt(0), ); const isValid = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, publicKey, signatureBytes.buffer, messageBytes, ); if (!isValid) { log.w(`Auth failed for clientId '${rq.body.client_id}'`); authFailed("Authentication request failed."); return; } } catch (err) { log.d(`Error when verifying auth request: ${err}`); authFailed("Authentication request failed."); return; } let user = new User(rq.body.client_id); if (!(await user.exists())) { const obj = await User.init({ client_id: rq.body.client_id, pubkey: rq.body.pubkey, }); if (obj == null) { log.w(`Obj null`); rs.sendStatus(500); return; } else user = obj; } if (!(await user.addNonce(rq.body.message.nonce))) { log.w( `Client '${rq.body.client_id}' has already used nonce. Replay attack?`, ); authFailed("Authentication request failed."); return; } user.addAssociatedIp(getSrcIpDefault(rq)); const token = await user.getToken(); rs.json({ token: token }); }, ); const checkRateLimit = new APIUtils.RateLimiter(10, 3); route.router.get('/checkExpired', checkRateLimit.middle(), async (rq, rs) => { const token = rq.header('GalvanicAuth'); if (!token) { rs.json(true); return; } try { const decodedToken = await decode(token, config.auth.secret, { algorithm: "HS512", leeway: 31536000 }); // 1 year leeway rs.json(decodedToken.exp < Math.round(Date.now() / 1000)); } catch { rs.json(true); } });