365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import { Request, Response, Express } from 'express';
|
|
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
|
import * as bcrypt from 'bcrypt';
|
|
import * as crypto from 'crypto';
|
|
import { Settings } from './types';
|
|
|
|
const BCRYPT_ROUNDS = 10;
|
|
const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days
|
|
const USERNAME_MIN = 2;
|
|
const PASSWORD_MIN = 6;
|
|
|
|
/** 1 USD = 4 RC (IAP packs) */
|
|
export const RC_PER_USD = 4;
|
|
|
|
/** Fixed packs: $5, $20, $50 */
|
|
export const RC_PACKS: Record<string, { usd: number; rc: number }> = {
|
|
usd_5: { usd: 5, rc: 5 * RC_PER_USD },
|
|
usd_20: { usd: 20, rc: 20 * RC_PER_USD },
|
|
usd_50: { usd: 50, rc: 50 * RC_PER_USD },
|
|
};
|
|
|
|
function isIapDummyMode(settings: Settings): boolean {
|
|
return settings.iap_dummy_mode === true;
|
|
}
|
|
|
|
export interface AuthUserPublic {
|
|
id: string;
|
|
username: string;
|
|
cc: number;
|
|
rc: number;
|
|
created_at: string;
|
|
last_logged_at: string | null;
|
|
}
|
|
|
|
function authConfigured(settings: Settings): boolean {
|
|
return Boolean(
|
|
settings.supabase_url?.trim() &&
|
|
settings.supabase_service_role_key?.trim() &&
|
|
settings.auth_jwt_secret?.trim()
|
|
);
|
|
}
|
|
|
|
/** Supabase URL + service role only (e.g. match history without full auth). */
|
|
export function supabaseConfigured(settings: Settings): boolean {
|
|
return Boolean(settings.supabase_url?.trim() && settings.supabase_service_role_key?.trim());
|
|
}
|
|
|
|
export function createSupabase(settings: Settings): SupabaseClient {
|
|
return createClient(settings.supabase_url!.trim(), settings.supabase_service_role_key!.trim(), {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
});
|
|
}
|
|
|
|
function signToken(userId: string, secret: string): string {
|
|
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC;
|
|
const payload = JSON.stringify({ sub: userId, exp });
|
|
const bodyB64 = Buffer.from(payload, 'utf8').toString('base64url');
|
|
const sig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url');
|
|
return `${bodyB64}.${sig}`;
|
|
}
|
|
|
|
export function verifyAuthToken(token: string, secret: string): { userId: string } | null {
|
|
const parts = token.split('.');
|
|
if (parts.length !== 2) return null;
|
|
const [bodyB64, sig] = parts;
|
|
const expectedSig = crypto.createHmac('sha256', secret).update(bodyB64).digest('base64url');
|
|
const a = Buffer.from(sig, 'utf8');
|
|
const b = Buffer.from(expectedSig, 'utf8');
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as {
|
|
sub?: string;
|
|
exp?: number;
|
|
};
|
|
if (!payload.sub || typeof payload.exp !== 'number') return null;
|
|
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return { userId: payload.sub };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readBearer(req: Request): string | null {
|
|
const h = req.headers.authorization;
|
|
if (!h || typeof h !== 'string') return null;
|
|
const m = /^Bearer\s+(.+)$/i.exec(h.trim());
|
|
return m ? m[1]!.trim() : null;
|
|
}
|
|
|
|
function coerceDbUserId(id: unknown): number | null {
|
|
if (id == null) return null;
|
|
if (typeof id === 'bigint') return Number(id);
|
|
if (typeof id === 'number' && Number.isFinite(id)) return id;
|
|
if (typeof id === 'string') {
|
|
const n = parseInt(id, 10);
|
|
return Number.isNaN(n) ? null : n;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface MatchmakingUser {
|
|
userId: number;
|
|
username: string;
|
|
}
|
|
|
|
/**
|
|
* Validates Bearer session token and loads the user from Supabase (id + username).
|
|
* Used by GET / and GET /cancel matchmaking.
|
|
*/
|
|
export async function authenticateMatchmakingRequest(
|
|
req: Request,
|
|
settings: Settings
|
|
): Promise<{ ok: true; user: MatchmakingUser } | { ok: false; status: number; message: string }> {
|
|
if (!authConfigured(settings)) {
|
|
return { ok: false, status: 503, message: 'Auth not configured' };
|
|
}
|
|
const token = readBearer(req);
|
|
if (!token) {
|
|
return { ok: false, status: 401, message: 'Missing Authorization: Bearer <token>' };
|
|
}
|
|
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
|
|
if (!v) {
|
|
return { ok: false, status: 401, message: 'Invalid or expired token' };
|
|
}
|
|
const supabase = createSupabase(settings);
|
|
const { data, error } = await supabase
|
|
.from('users')
|
|
.select('id, username')
|
|
.eq('id', v.userId)
|
|
.maybeSingle();
|
|
if (error || !data || typeof data.username !== 'string' || data.username.length < 2) {
|
|
return { ok: false, status: 404, message: 'User not found' };
|
|
}
|
|
const userId = coerceDbUserId(data.id);
|
|
if (userId == null) {
|
|
return { ok: false, status: 500, message: 'Invalid user id' };
|
|
}
|
|
return { ok: true, user: { userId, username: data.username } };
|
|
}
|
|
|
|
export function registerAuthRoutes(app: Express, settings: Settings): void {
|
|
app.post('/auth/register', async (req: Request, res: Response) => {
|
|
if (!authConfigured(settings)) {
|
|
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
|
|
return;
|
|
}
|
|
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
|
|
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
if (username.length < USERNAME_MIN) {
|
|
res.status(400).json({ ok: false, error: `Username must be at least ${USERNAME_MIN} characters` });
|
|
return;
|
|
}
|
|
if (password.length < PASSWORD_MIN) {
|
|
res.status(400).json({ ok: false, error: `Password must be at least ${PASSWORD_MIN} characters` });
|
|
return;
|
|
}
|
|
|
|
const supabase = createSupabase(settings);
|
|
const { data: existing } = await supabase.from('users').select('id').eq('username', username).maybeSingle();
|
|
if (existing) {
|
|
res.status(409).json({ ok: false, error: 'Username already taken' });
|
|
return;
|
|
}
|
|
|
|
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
const { data: row, error } = await supabase
|
|
.from('users')
|
|
.insert({
|
|
username,
|
|
password: hash,
|
|
cc: 0,
|
|
rc: 0,
|
|
})
|
|
.select('id, username, cc, rc, created_at, last_logged_at')
|
|
.single();
|
|
|
|
if (error || !row) {
|
|
res.status(500).json({ ok: false, error: error?.message ?? 'Registration failed' });
|
|
return;
|
|
}
|
|
|
|
const user: AuthUserPublic = {
|
|
id: row.id,
|
|
username: row.username,
|
|
cc: row.cc,
|
|
rc: row.rc,
|
|
created_at: row.created_at,
|
|
last_logged_at: row.last_logged_at ?? null,
|
|
};
|
|
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
|
|
res.status(201).json({ ok: true, token, user });
|
|
});
|
|
|
|
app.post('/auth/login', async (req: Request, res: Response) => {
|
|
if (!authConfigured(settings)) {
|
|
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
|
|
return;
|
|
}
|
|
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
|
|
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
if (username.length < USERNAME_MIN || !password) {
|
|
res.status(400).json({ ok: false, error: 'Invalid username or password' });
|
|
return;
|
|
}
|
|
|
|
const supabase = createSupabase(settings);
|
|
const { data: row, error } = await supabase
|
|
.from('users')
|
|
.select('id, username, password, cc, rc, created_at')
|
|
.eq('username', username)
|
|
.maybeSingle();
|
|
|
|
if (error || !row) {
|
|
res.status(401).json({ ok: false, error: 'Invalid username or password' });
|
|
return;
|
|
}
|
|
|
|
const match = await bcrypt.compare(password, row.password);
|
|
if (!match) {
|
|
res.status(401).json({ ok: false, error: 'Invalid username or password' });
|
|
return;
|
|
}
|
|
|
|
const nowIso = new Date().toISOString();
|
|
await supabase.from('users').update({ last_logged_at: nowIso }).eq('id', row.id);
|
|
|
|
const user: AuthUserPublic = {
|
|
id: row.id,
|
|
username: row.username,
|
|
cc: row.cc,
|
|
rc: row.rc,
|
|
created_at: row.created_at,
|
|
last_logged_at: nowIso,
|
|
};
|
|
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
|
|
res.json({ ok: true, token, user });
|
|
});
|
|
|
|
/** Current user from DB (requires login token). Same behavior as GET /auth/user. */
|
|
const handleGetCurrentUser = async (req: Request, res: Response) => {
|
|
if (!authConfigured(settings)) {
|
|
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
|
|
return;
|
|
}
|
|
const token = readBearer(req);
|
|
if (!token) {
|
|
res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer <token>' });
|
|
return;
|
|
}
|
|
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
|
|
if (!v) {
|
|
res.status(401).json({ ok: false, error: 'Invalid or expired token' });
|
|
return;
|
|
}
|
|
const supabase = createSupabase(settings);
|
|
const { data, error } = await supabase
|
|
.from('users')
|
|
.select('id, username, cc, rc, created_at, last_logged_at')
|
|
.eq('id', v.userId)
|
|
.maybeSingle();
|
|
if (error || !data) {
|
|
res.status(404).json({ ok: false, error: 'User not found' });
|
|
return;
|
|
}
|
|
const user: AuthUserPublic = {
|
|
id: data.id,
|
|
username: data.username,
|
|
cc: data.cc,
|
|
rc: data.rc,
|
|
created_at: data.created_at,
|
|
last_logged_at: data.last_logged_at ?? null,
|
|
};
|
|
res.json({ ok: true, user });
|
|
};
|
|
|
|
app.get('/auth/me', handleGetCurrentUser);
|
|
app.get('/auth/user', handleGetCurrentUser);
|
|
|
|
/** Public catalog for Unity UI (no auth). */
|
|
app.get('/auth/rc-packs', (_req: Request, res: Response) => {
|
|
const packs = Object.entries(RC_PACKS).map(([id, p]) => ({
|
|
id,
|
|
usd: p.usd,
|
|
rc: p.rc,
|
|
}));
|
|
res.json({ ok: true, rc_per_usd: RC_PER_USD, packs });
|
|
});
|
|
|
|
/**
|
|
* Dummy IAP: adds RC for a named pack when `iap_dummy_mode` is true in settings.
|
|
* Later: same route can require a verified store receipt when dummy mode is off.
|
|
*/
|
|
app.post('/auth/purchase-rc', async (req: Request, res: Response) => {
|
|
if (!authConfigured(settings)) {
|
|
res.status(503).json({ ok: false, error: 'Auth is not configured on the server' });
|
|
return;
|
|
}
|
|
if (!isIapDummyMode(settings)) {
|
|
res.status(403).json({
|
|
ok: false,
|
|
error: 'Dummy purchases are disabled. Configure server-side purchase verification.',
|
|
});
|
|
return;
|
|
}
|
|
const token = readBearer(req);
|
|
if (!token) {
|
|
res.status(401).json({ ok: false, error: 'Missing Authorization: Bearer <token>' });
|
|
return;
|
|
}
|
|
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
|
|
if (!v) {
|
|
res.status(401).json({ ok: false, error: 'Invalid or expired token' });
|
|
return;
|
|
}
|
|
const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : '';
|
|
const pack = RC_PACKS[packId];
|
|
if (!pack) {
|
|
const valid = Object.keys(RC_PACKS).join(', ');
|
|
res.status(400).json({
|
|
ok: false,
|
|
error: `Unknown pack "${packId}". Valid: ${valid}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const supabase = createSupabase(settings);
|
|
const { data: row, error: selErr } = await supabase
|
|
.from('users')
|
|
.select('rc')
|
|
.eq('id', v.userId)
|
|
.maybeSingle();
|
|
if (selErr || row === null) {
|
|
res.status(404).json({ ok: false, error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
const newRc = (row.rc ?? 0) + pack.rc;
|
|
const { data: updated, error: upErr } = await supabase
|
|
.from('users')
|
|
.update({ rc: newRc })
|
|
.eq('id', v.userId)
|
|
.select('id, username, cc, rc, created_at, last_logged_at')
|
|
.single();
|
|
|
|
if (upErr || !updated) {
|
|
res.status(500).json({ ok: false, error: upErr?.message ?? 'Failed to update balance' });
|
|
return;
|
|
}
|
|
|
|
const user: AuthUserPublic = {
|
|
id: updated.id,
|
|
username: updated.username,
|
|
cc: updated.cc,
|
|
rc: updated.rc,
|
|
created_at: updated.created_at,
|
|
last_logged_at: updated.last_logged_at ?? null,
|
|
};
|
|
res.json({
|
|
ok: true,
|
|
pack: { id: packId, usd: pack.usd, rc_added: pack.rc },
|
|
user,
|
|
});
|
|
});
|
|
}
|