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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user