Harden auth, payments, and frontend against review findings.

Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-24 19:59:02 +00:00
co-authored by Cursor
parent fc4af38e8a
commit a6840ea953
37 changed files with 1432 additions and 528 deletions
+43 -36
View File
@@ -2,8 +2,8 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
@@ -37,6 +37,8 @@ dashboard.get('/profile', async (c) => {
const now = new Date();
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
const hasPassword = !!(await getUserPasswordHash(user.id));
return c.json({
profile: {
id: user.id,
@@ -47,7 +49,7 @@ dashboard.get('/profile', async (c) => {
rucNumber: user.rucNumber,
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword: !!user.password,
hasPassword,
hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt,
membershipDays,
@@ -103,34 +105,31 @@ dashboard.get('/tickets', async (c) => {
.where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt))
);
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
const ticketIds = userTickets.map((t: any) => t.id);
const eventRows = eventIds.length
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
: [];
const paymentRows = ticketIds.length
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
: [];
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
const invoiceRows = paidPaymentIds.length
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
: [];
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
// Get event details for each ticket
const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Check for invoice
let invoice: any = null;
if (payment && payment.status === 'paid') {
invoice = await dbGet<any>(
(db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
);
}
const ticketsWithEvents = userTickets.map((ticket: any) => {
const event = eventsById.get(ticket.eventId);
const payment = paymentsByTicketId.get(ticket.id);
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
return {
...ticket,
@@ -162,8 +161,7 @@ dashboard.get('/tickets', async (c) => {
createdAt: invoice.createdAt,
} : null,
};
})
);
});
return c.json({ tickets: ticketsWithEvents });
});
@@ -452,13 +450,22 @@ dashboard.delete('/sessions/:id', async (c) => {
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere)
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
// every previously issued JWT for this user, which is the actual enforcement
// mechanism (auth is stateless JWT, not DB-session based).
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
// Issue a fresh token so the current device stays signed in
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All sessions revoked. Please log in again.' });
return c.json({ message: 'All other sessions revoked.', token });
});
// Set password (for users without one)
@@ -471,7 +478,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
const { password } = c.req.valid('json');
// Check if user already has a password
if (user.password) {
if (await getUserPasswordHash(user.id)) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
@@ -502,7 +509,7 @@ dashboard.post('/unlink-google', async (c) => {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!user.password) {
if (!(await getUserPasswordHash(user.id))) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}