threesome fixed
This commit is contained in:
+152
-76
@@ -7,6 +7,8 @@ import { logDebug, logInfo } from './server_log';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
const TOKEN_TTL_SEC = 60 * 60 * 24 * 7; // 7 days
|
||||
/** Unity should POST /auth/keepalive every 10s. Session stays active for 3 missed pings. */
|
||||
const SESSION_ACTIVE_MS = 30_000;
|
||||
const USERNAME_MIN = 2;
|
||||
const PASSWORD_MIN = 6;
|
||||
const EMAIL_MAX = 254;
|
||||
@@ -55,15 +57,15 @@ export function createSupabase(settings: Settings): SupabaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
function signToken(userId: string, secret: string): string {
|
||||
function signToken(userId: string, sessionId: string, secret: string): string {
|
||||
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SEC;
|
||||
const payload = JSON.stringify({ sub: userId, exp });
|
||||
const payload = JSON.stringify({ sub: userId, exp, jti: sessionId });
|
||||
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 {
|
||||
export function verifyAuthToken(token: string, secret: string): { userId: string; sessionId: string } | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [bodyB64, sig] = parts;
|
||||
@@ -75,15 +77,74 @@ export function verifyAuthToken(token: string, secret: string): { userId: string
|
||||
const payload = JSON.parse(Buffer.from(bodyB64, 'base64url').toString('utf8')) as {
|
||||
sub?: string;
|
||||
exp?: number;
|
||||
jti?: string;
|
||||
};
|
||||
if (!payload.sub || typeof payload.exp !== 'number') return null;
|
||||
if (typeof payload.jti !== 'string' || payload.jti.length < 1) return null;
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return { userId: payload.sub };
|
||||
return { userId: payload.sub, sessionId: payload.jti };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionActive(lastKeepaliveAt: unknown): boolean {
|
||||
if (lastKeepaliveAt == null || lastKeepaliveAt === '') return false;
|
||||
const ms = Date.parse(String(lastKeepaliveAt));
|
||||
if (!Number.isFinite(ms)) return false;
|
||||
return Date.now() - ms < SESSION_ACTIVE_MS;
|
||||
}
|
||||
|
||||
type AuthSessionOk = {
|
||||
ok: true;
|
||||
supabase: SupabaseClient;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
row: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AuthSessionFail = { ok: false; status: number; error: string };
|
||||
|
||||
/**
|
||||
* Verifies Bearer token, loads the user, and requires users.session_id === token jti.
|
||||
*/
|
||||
async function authenticateSession(
|
||||
req: Request,
|
||||
settings: Settings
|
||||
): Promise<AuthSessionOk | AuthSessionFail> {
|
||||
if (!authConfigured(settings)) {
|
||||
return { ok: false, status: 503, error: 'Auth is not configured on the server' };
|
||||
}
|
||||
const token = readBearer(req);
|
||||
if (!token) {
|
||||
return { ok: false, status: 401, error: 'Missing Authorization: Bearer <token>' };
|
||||
}
|
||||
const v = verifyAuthToken(token, settings.auth_jwt_secret!.trim());
|
||||
if (!v) {
|
||||
return { ok: false, status: 401, error: 'Invalid or expired token' };
|
||||
}
|
||||
const supabase = createSupabase(settings);
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('id, session_id, username, email, cc, rc, created_at, last_logged_at')
|
||||
.eq('id', v.userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) {
|
||||
return { ok: false, status: 404, error: 'User not found' };
|
||||
}
|
||||
const stored = typeof data.session_id === 'string' ? data.session_id : '';
|
||||
if (!stored || stored !== v.sessionId) {
|
||||
return { ok: false, status: 401, error: 'Invalid or expired token' };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
supabase,
|
||||
userId: v.userId,
|
||||
sessionId: v.sessionId,
|
||||
row: data as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function readBearer(req: Request): string | null {
|
||||
const h = req.headers.authorization;
|
||||
if (!h || typeof h !== 'string') return null;
|
||||
@@ -176,31 +237,19 @@ 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 auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
const message = auth.status === 503 ? 'Auth not configured' : auth.error;
|
||||
return { ok: false, status: auth.status, message };
|
||||
}
|
||||
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) {
|
||||
if (typeof auth.row.username !== 'string' || auth.row.username.length < 2) {
|
||||
return { ok: false, status: 404, message: 'User not found' };
|
||||
}
|
||||
const userId = coerceDbUserId(data.id);
|
||||
const userId = coerceDbUserId(auth.row.id);
|
||||
if (userId == null) {
|
||||
return { ok: false, status: 500, message: 'Invalid user id' };
|
||||
}
|
||||
return { ok: true, user: { userId, username: data.username } };
|
||||
return { ok: true, user: { userId, username: auth.row.username } };
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
@@ -242,6 +291,8 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
|
||||
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
const clientIp = readClientIp(req);
|
||||
const sessionId = crypto.randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
const { data: row, error } = await supabase
|
||||
.from('users')
|
||||
.insert({
|
||||
@@ -251,6 +302,9 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
cc: 0,
|
||||
rc: 0,
|
||||
ip_address: clientIp,
|
||||
session_id: sessionId,
|
||||
last_keepalive_at: nowIso,
|
||||
last_logged_at: nowIso,
|
||||
})
|
||||
.select('id, username, email, cc, rc, created_at, last_logged_at')
|
||||
.single();
|
||||
@@ -272,7 +326,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
}
|
||||
|
||||
const user = rowToAuthUserPublic(row as Record<string, unknown>);
|
||||
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
|
||||
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
|
||||
res.status(201).json({ ok: true, token, user });
|
||||
});
|
||||
|
||||
@@ -291,7 +345,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
const supabase = createSupabase(settings);
|
||||
const { data: row, error } = await supabase
|
||||
.from('users')
|
||||
.select('id, username, email, password, cc, rc, created_at')
|
||||
.select('id, username, email, password, cc, rc, created_at, session_id, last_keepalive_at')
|
||||
.eq('username', username)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -306,45 +360,82 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingSession = typeof row.session_id === 'string' ? row.session_id : '';
|
||||
if (existingSession && isSessionActive(row.last_keepalive_at)) {
|
||||
res.status(409).json({
|
||||
ok: false,
|
||||
error: 'Already logged in on another device',
|
||||
code: 'already_logged_in',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
const clientIp = readClientIp(req);
|
||||
await supabase.from('users').update({ last_logged_at: nowIso, ip_address: clientIp }).eq('id', row.id);
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
last_logged_at: nowIso,
|
||||
ip_address: clientIp,
|
||||
session_id: sessionId,
|
||||
last_keepalive_at: nowIso,
|
||||
})
|
||||
.eq('id', row.id);
|
||||
|
||||
const user: AuthUserPublic = {
|
||||
...rowToAuthUserPublic(row as Record<string, unknown>),
|
||||
last_logged_at: nowIso,
|
||||
};
|
||||
const token = signToken(user.id, settings.auth_jwt_secret!.trim());
|
||||
const token = signToken(user.id, sessionId, settings.auth_jwt_secret!.trim());
|
||||
res.json({ ok: true, token, user });
|
||||
});
|
||||
|
||||
app.post('/auth/keepalive', async (req: Request, res: Response) => {
|
||||
const auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
res.status(auth.status).json({ ok: false, error: auth.error });
|
||||
return;
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
const { error } = await auth.supabase
|
||||
.from('users')
|
||||
.update({ last_keepalive_at: nowIso })
|
||||
.eq('id', auth.userId)
|
||||
.eq('session_id', auth.sessionId);
|
||||
if (error) {
|
||||
res.status(500).json({ ok: false, error: error.message ?? 'Keepalive failed' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (req: Request, res: Response) => {
|
||||
const auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
res.status(auth.status).json({ ok: false, error: auth.error });
|
||||
return;
|
||||
}
|
||||
const { error } = await auth.supabase
|
||||
.from('users')
|
||||
.update({ session_id: null, last_keepalive_at: null })
|
||||
.eq('id', auth.userId)
|
||||
.eq('session_id', auth.sessionId);
|
||||
if (error) {
|
||||
res.status(500).json({ ok: false, error: error.message ?? 'Logout failed' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
/** 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' });
|
||||
const auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
res.status(auth.status).json({ ok: false, error: auth.error });
|
||||
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, email, 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;
|
||||
}
|
||||
res.json({ ok: true, user: rowToAuthUserPublic(data as Record<string, unknown>) });
|
||||
res.json({ ok: true, user: rowToAuthUserPublic(auth.row) });
|
||||
};
|
||||
|
||||
app.get('/auth/me', handleGetCurrentUser);
|
||||
@@ -362,18 +453,9 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
|
||||
/** Client reports observed ping for a match. User id comes from login token. */
|
||||
app.post('/auth/ping-report', 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' });
|
||||
const auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
res.status(auth.status).json({ ok: false, error: auth.error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -394,15 +476,14 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = coerceDbUserId(v.userId);
|
||||
const userId = coerceDbUserId(auth.userId);
|
||||
if (userId == null || userId < 1) {
|
||||
res.status(400).json({ ok: false, error: 'Invalid token user id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createSupabase(settings);
|
||||
const ipAddress = readClientIp(req);
|
||||
const { data: inserted, error } = await supabase
|
||||
const { data: inserted, error } = await auth.supabase
|
||||
.from('ping_reports')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
@@ -440,16 +521,12 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
});
|
||||
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' });
|
||||
const auth = await authenticateSession(req, settings);
|
||||
if (!auth.ok) {
|
||||
res.status(auth.status).json({ ok: false, error: auth.error });
|
||||
return;
|
||||
}
|
||||
const supabase = auth.supabase;
|
||||
const packId = typeof req.body?.pack === 'string' ? req.body.pack.trim() : '';
|
||||
const pack = RC_PACKS[packId];
|
||||
if (!pack) {
|
||||
@@ -461,8 +538,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createSupabase(settings);
|
||||
const buyerId = coerceDbUserId(v.userId);
|
||||
const buyerId = coerceDbUserId(auth.userId);
|
||||
if (buyerId == null || buyerId < 1) {
|
||||
res.status(400).json({ ok: false, error: 'Invalid token user id' });
|
||||
return;
|
||||
@@ -479,7 +555,7 @@ export function registerAuthRoutes(app: Express, settings: Settings): void {
|
||||
self_purchase: selfPurchase,
|
||||
});
|
||||
|
||||
const { data: rpcData, error: rpcErr } = await supabase.rpc('purchase_rc', {
|
||||
const { data: rpcData, error: rpcErr } = await auth.supabase.rpc('purchase_rc', {
|
||||
p_buyer_id: buyerId,
|
||||
p_amount: amount,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user