This repository has been archived on 2026-03-19. You can view files and clone it, but cannot push or open issues or pull requests.
Files
galvanic-corrosion/src/routes/user.ts
zombieb 463e3ef71b Replace legacy checkBodyType with Zod
Start matchmaking integration
Start rooms API
Move existing room scene locations to roomtypes file
Auth checkExpired util for client refreshing
2025-03-25 21:54:08 -04:00

147 lines
4.4 KiB
TypeScript

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<NoBody, NoBody, AuthRequestRoot>,
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<UserTokenFormat>(token, config.auth.secret, { algorithm: "HS512", leeway: 31536000 }); // 1 year leeway
rs.json(decodedToken.exp < Math.round(Date.now() / 1000));
} catch {
rs.json(true);
}
});