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
+92 -13
View File
@@ -4,10 +4,21 @@ import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm';
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
import { generateId, getNow, toDbDate } from './utils.js';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
const rawJwtSecret = process.env.JWT_SECRET;
// Never allow the insecure default in production: forgeable tokens = full account takeover.
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
}
if (!rawJwtSecret) {
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
}
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
@@ -15,6 +26,7 @@ export interface JWTPayload {
sub: string;
email: string;
role: string;
tokenVersion?: number;
iat: number;
exp: number;
}
@@ -84,23 +96,36 @@ export async function verifyMagicLinkToken(
)
);
// Use a single generic error for all invalid states to avoid leaking token state
const genericError = 'Invalid or expired token';
if (!tokenRecord) {
return { valid: false, error: 'Invalid token' };
return { valid: false, error: genericError };
}
if (tokenRecord.usedAt) {
return { valid: false, error: 'Token already used' };
return { valid: false, error: genericError };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: 'Token expired' };
return { valid: false, error: genericError };
}
// Mark token as used
await (db as any)
// Atomically consume the token: only the request that flips used_at from NULL wins.
// This prevents a double-spend race where two concurrent requests both pass the
// read-time "not used" check above.
const result: any = await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
.where(and(
eq((magicLinkTokens as any).id, tokenRecord.id),
isNull((magicLinkTokens as any).usedAt)
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected === 0) {
return { valid: false, error: genericError };
}
return { valid: true, userId: tokenRecord.userId };
}
@@ -175,18 +200,26 @@ export function validatePassword(password: string): { valid: boolean; error?: st
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role })
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('7d')
.setExpirationTime('1d')
.sign(JWT_SECRET);
return token;
}
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
export async function bumpTokenVersion(userId: string): Promise<void> {
await (db as any)
.update(users)
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
.where(eq((users as any).id, userId));
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
@@ -223,10 +256,44 @@ export async function getAuthUser(c: Context): Promise<any | null> {
return null;
}
// Never load the password hash into request context — it is only needed for
// explicit password-verification routes that query it separately.
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
(db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
googleId: (users as any).googleId,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
tokenVersion: (users as any).tokenVersion,
createdAt: (users as any).createdAt,
updatedAt: (users as any).updatedAt,
})
.from(users)
.where(eq((users as any).id, payload.sub))
);
return user || null;
if (!user) {
return null;
}
// Reject tokens issued before a logout-everywhere / password change
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
return null;
}
// Suspended/unclaimed accounts must not retain API access via an old JWT
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) {
@@ -252,3 +319,15 @@ export async function isFirstUser(): Promise<boolean> {
);
return !result || result.length === 0;
}
/** Fetch only the password hash column (never expose via getAuthUser). */
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (users as any).password })
.from(users)
.where(eq((users as any).id, userId))
);
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}