Botanica/apps/server/src/auth.ts

303 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
randomBytes,
randomUUID,
createHash,
timingSafeEqual,
} from "node:crypto";
import { hash, verify, Algorithm } from "@node-rs/argon2";
import type { Pool } from "pg";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
import { ApiError } from "./errors.js";
import { GAME, levelFor } from "./game-config.js";
export const digest = (value: string) =>
createHash("sha256").update(value).digest("hex");
declare module "fastify" {
interface FastifyRequest {
identity?: Identity;
}
}
export interface Identity {
id: string;
username: string;
csrfToken: string;
}
const credentials = z.strictObject({
username: z
.string()
.trim()
.regex(/^[a-zA-Z0-9_]{3,24}$/, "Use 3–24 letters, numbers or underscores."),
password: z.string().min(10).max(128),
});
export function allowedOrigin(request: FastifyRequest) {
const origin = request.headers.origin;
if (!origin) return false;
const defaults = [
"http://localhost:8155",
"http://127.0.0.1:8155",
"http://192.168.1.197:8155",
"https://botanica.p20.dev",
];
return (
process.env.PUBLIC_ORIGINS?.split(",").map((s) => s.trim()) ?? defaults
).includes(origin);
}
function cookie(
reply: FastifyReply,
request: FastifyRequest,
value: string,
maxAge = 2592000,
) {
const secure =
request.headers.origin?.startsWith("https://") ||
request.headers["x-forwarded-proto"] === "https";
reply.header(
"set-cookie",
`botanica_session=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? "; Secure" : ""}`,
);
}
export function registerAuth(app: FastifyInstance, pool: Pool) {
const attempts = new Map<string, { count: number; until: number }>();
function throttle(key: string, limit: number) {
const now = Date.now(),
previous = attempts.get(key);
if (previous && previous.until > now) {
if (++previous.count <= limit) return;
} else {
if (attempts.size >= 4096)
for (const [id, entry] of attempts)
if (entry.until <= now) attempts.delete(id);
if (attempts.size < 4096) {
attempts.set(key, { count: 1, until: now + 900000 });
return;
}
}
throw new ApiError(
429,
"AUTH_THROTTLED",
"Too many attempts. Try again in 15 minutes.",
);
}
app.addHook("preHandler", async (request) => {
if (request.url.startsWith("/api/v1/auth/") && request.method === "POST") {
if (!allowedOrigin(request))
throw new ApiError(
403,
"ORIGIN_REJECTED",
"Use this application’s own origin.",
);
const username =
typeof request.body === "object" &&
request.body !== null &&
"username" in request.body &&
typeof request.body.username === "string"
? request.body.username.toLowerCase().slice(0, 24)
: "";
throttle("ip:" + request.ip, 100);
throttle(request.ip + ":" + username, 20);
}
});
async function startSession(
id: string,
reply: FastifyReply,
request: FastifyRequest,
) {
const token = randomBytes(32).toString("hex");
await pool.query(
"INSERT INTO sessions(token_hash,player_id,expires_at) VALUES($1,$2,now()+interval '30 days')",
[digest(token), id],
);
cookie(reply, request, token);
return { csrfToken: digest("csrf:" + token) };
}
app.post("/api/v1/auth/register", async (request, reply) => {
const input = credentials.parse(request.body),
passwordHash = await hash(input.password, {
algorithm: Algorithm.Argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
}),
id = randomUUID(),
client = await pool.connect(),
codes = Array.from({ length: 5 }, () => randomBytes(12).toString("hex"));
try {
await client.query("BEGIN");
const inserted = await client.query(
"INSERT INTO players(id,display_name,normalized_username,password_hash,coin_balance) VALUES($1,$2,$3,$4,$5) ON CONFLICT(normalized_username) DO NOTHING RETURNING id",
[
id,
input.username,
input.username.toLowerCase(),
passwordHash,
GAME.starterCoins,
],
);
if (!inserted.rowCount)
throw new ApiError(
409,
"USERNAME_TAKEN",
"That username is already in use.",
);
await client.query(
"INSERT INTO inventory_seeds VALUES($1,'starter',$2)",
[id, GAME.starterSeeds],
);
const house = randomUUID();
await client.query(
"INSERT INTO greenhouses(id,player_id,name) VALUES($1,$2,$3)",
[house, id, "Little Glasshouse"],
);
for (let i = 0; i < 4; i++)
await client.query(
"INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)",
[randomUUID(), house, i],
);
for (const code of codes)
await client.query(
"INSERT INTO recovery_codes(player_id,code_hash) VALUES($1,$2)",
[id, digest(code)],
);
await client.query(
"INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) VALUES($1,$2::uuid,$3,$3,'welcome',$2::uuid::text)",
[randomUUID(), id, GAME.starterCoins],
);
await client.query("COMMIT");
return reply.code(201).send({
...(await startSession(id, reply, request)),
recoveryCodes: codes,
});
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
});
app.post("/api/v1/auth/login", async (request, reply) => {
const input = credentials.parse(request.body),
row = (
await pool.query(
"SELECT id,password_hash FROM players WHERE normalized_username=$1",
[input.username.toLowerCase()],
)
).rows[0];
if (!row || !(await verify(row.password_hash, input.password)))
throw new ApiError(
401,
"INVALID_LOGIN",
"Username or password is incorrect.",
);
return startSession(row.id, reply, request);
});
app.post("/api/v1/auth/recover", async (request, reply) => {
const input = credentials
.extend({ recoveryCode: z.string().regex(/^[a-f0-9]{24}$/) })
.parse(request.body),
passwordHash = await hash(input.password, {
algorithm: Algorithm.Argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
}),
client = await pool.connect();
try {
await client.query("BEGIN");
const row = (
await client.query(
"UPDATE recovery_codes c SET used_at=now() FROM players p WHERE c.player_id=p.id AND p.normalized_username=$1 AND c.code_hash=$2 AND c.used_at IS NULL RETURNING p.id",
[input.username.toLowerCase(), digest(input.recoveryCode)],
)
).rows[0];
if (!row)
throw new ApiError(
401,
"INVALID_RECOVERY",
"Username or recovery code is incorrect or already used.",
);
await client.query("UPDATE players SET password_hash=$2 WHERE id=$1", [
row.id,
passwordHash,
]);
await client.query("DELETE FROM sessions WHERE player_id=$1", [row.id]);
await client.query("COMMIT");
return startSession(row.id, reply, request);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
});
app.post("/api/v1/auth/logout", async (request, reply) => {
const identity = await authenticate(pool, request);
checkCsrf(request, identity);
await pool.query("DELETE FROM sessions WHERE token_hash=$1", [
digest(sessionToken(request)),
]);
cookie(reply, request, "", 0);
return { ok: true };
});
app.get("/api/v1/me", async (request) => {
const identity = await authenticate(pool, request),
row = (
await pool.query("SELECT coin_balance,xp FROM players WHERE id=$1", [
identity.id,
])
).rows[0];
return {
...identity,
coins: row.coin_balance,
xp: row.xp,
...levelFor(row.xp),
};
});
}
function sessionToken(request: FastifyRequest) {
return (
request.headers.cookie
?.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith("botanica_session="))
?.slice(17) ?? ""
);
}
export async function authenticate(
pool: Pool,
request: FastifyRequest,
): Promise<Identity> {
const token = sessionToken(request);
if (!/^[a-f0-9]{64}$/.test(token))
throw new ApiError(401, "AUTH_REQUIRED", "Sign in to your greenhouse.");
const row = (
await pool.query(
"SELECT p.id,p.display_name FROM sessions s JOIN players p ON p.id=s.player_id WHERE s.token_hash=$1 AND s.expires_at>now()",
[digest(token)],
)
).rows[0];
if (!row)
throw new ApiError(
401,
"AUTH_REQUIRED",
"Your session expired. Please sign in.",
);
return {
id: row.id,
username: row.display_name,
csrfToken: digest("csrf:" + token),
};
}
export function checkCsrf(request: FastifyRequest, identity: Identity) {
const token = request.headers["x-csrf-token"];
if (
!allowedOrigin(request) ||
typeof token !== "string" ||
!/^[a-f0-9]{64}$/.test(token) ||
!timingSafeEqual(Buffer.from(token), Buffer.from(identity.csrfToken))
)
throw new ApiError(
403,
"CSRF_REJECTED",
"Refresh the page before trying this action again.",
);
}