import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js'; import { eq } from 'drizzle-orm'; import { hashPassword, verifyPassword, createToken, createRefreshToken, isFirstUser, getAuthUser, validatePassword, createMagicLinkToken, verifyMagicLinkToken, invalidateAllUserSessions, requireAuth, } from '../lib/auth.js'; import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { sendEmail } from '../lib/email.js'; // User type that includes all fields (some added in schema updates) type AuthUser = User & { isClaimed: boolean; googleId: string | null; rucNumber: string | null; accountStatus: string; }; const auth = new Hono(); // Rate limiting store (in production, use Redis) const loginAttempts = new Map(); const MAX_LOGIN_ATTEMPTS = 5; const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } { const now = Date.now(); const attempts = loginAttempts.get(email); if (!attempts) { return { allowed: true }; } if (now > attempts.resetAt) { loginAttempts.delete(email); return { allowed: true }; } if (attempts.count >= MAX_LOGIN_ATTEMPTS) { return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) }; } return { allowed: true }; } function recordFailedAttempt(email: string): void { const now = Date.now(); const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION }; attempts.count++; loginAttempts.set(email, attempts); } function clearFailedAttempts(email: string): void { loginAttempts.delete(email); } const registerSchema = z.object({ email: z.string().email(), password: z.string().min(10, 'Password must be at least 10 characters'), name: z.string().min(2), phone: z.string().optional(), languagePreference: z.enum(['en', 'es']).optional(), }); const loginSchema = z.object({ email: z.string().email(), password: z.string(), }); const magicLinkRequestSchema = z.object({ email: z.string().email(), }); const magicLinkVerifySchema = z.object({ token: z.string(), }); const passwordResetRequestSchema = z.object({ email: z.string().email(), }); const passwordResetSchema = z.object({ token: z.string(), password: z.string().min(10, 'Password must be at least 10 characters'), }); const claimAccountSchema = z.object({ token: z.string(), password: z.string().min(10, 'Password must be at least 10 characters').optional(), googleId: z.string().optional(), }); const changePasswordSchema = z.object({ currentPassword: z.string(), newPassword: z.string().min(10, 'Password must be at least 10 characters'), }); const googleAuthSchema = z.object({ credential: z.string(), // Google ID token }); // Register auth.post('/register', zValidator('json', registerSchema), async (c) => { const data = c.req.valid('json'); // Validate password strength const passwordValidation = validatePassword(data.password); if (!passwordValidation.valid) { return c.json({ error: passwordValidation.error }, 400); } // Check if email exists const existing = await dbGet( (db as any).select().from(users).where(eq((users as any).email, data.email)) ); if (existing) { // If user exists but is unclaimed, allow claiming if (!existing.isClaimed || existing.accountStatus === 'unclaimed') { return c.json({ error: 'Email already registered', canClaim: true, message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.' }, 400); } return c.json({ error: 'Email already registered' }, 400); } // Check if first user (becomes admin) const firstUser = await isFirstUser(); const hashedPassword = await hashPassword(data.password); const now = getNow(); const id = generateId(); const newUser = { id, email: data.email, password: hashedPassword, name: data.name, phone: data.phone || null, role: firstUser ? 'admin' : 'user', languagePreference: data.languagePreference || null, isClaimed: toDbBool(true), googleId: null, rucNumber: null, accountStatus: 'active', createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(newUser); const token = await createToken(id, data.email, newUser.role); const refreshToken = await createRefreshToken(id); return c.json({ user: { id, email: data.email, name: data.name, role: newUser.role, isClaimed: true, }, token, refreshToken, message: firstUser ? 'Admin account created successfully' : 'Account created successfully', }, 201); }); // Login with email/password auth.post('/login', zValidator('json', loginSchema), async (c) => { const data = c.req.valid('json'); // Check rate limit const rateLimit = checkRateLimit(data.email); if (!rateLimit.allowed) { return c.json({ error: 'Too many login attempts. Please try again later.', retryAfter: rateLimit.retryAfter }, 429); } const user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, data.email)) ); if (!user) { recordFailedAttempt(data.email); return c.json({ error: 'Invalid credentials' }, 401); } // Check if account is suspended if (user.accountStatus === 'suspended') { return c.json({ error: 'Account is suspended. Please contact support.' }, 403); } // Check if user has a password set if (!user.password) { return c.json({ error: 'No password set for this account', needsClaim: !user.isClaimed, message: user.isClaimed ? 'Please use Google login or request a password reset.' : 'Please claim your account first.' }, 400); } const validPassword = await verifyPassword(data.password, user.password); if (!validPassword) { recordFailedAttempt(data.email); return c.json({ error: 'Invalid credentials' }, 401); } // Clear failed attempts on successful login clearFailedAttempts(data.email); const token = await createToken(user.id, user.email, user.role); const refreshToken = await createRefreshToken(user.id); return c.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role, isClaimed: user.isClaimed, phone: user.phone, rucNumber: user.rucNumber, languagePreference: user.languagePreference, }, token, refreshToken, }); }); // Request magic link login auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, email)) ); if (!user) { // Don't reveal if email exists return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); } if (user.accountStatus === 'suspended') { return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); } // Create magic link token (expires in 10 minutes) const token = await createMagicLinkToken(user.id, 'login', 10); const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`; // Send email try { await sendEmail({ to: email, subject: 'Your Spanglish Login Link', html: `

Login to Spanglish

Click the link below to log in. This link expires in 10 minutes.

Log In

Or copy this link: ${magicLink}

If you didn't request this, you can safely ignore this email.

`, }); } catch (error) { console.error('Failed to send magic link email:', error); } return c.json({ message: 'If an account exists with this email, a login link has been sent.' }); }); // Verify magic link and login auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => { const { token } = c.req.valid('json'); const verification = await verifyMagicLinkToken(token, 'login'); if (!verification.valid) { return c.json({ error: verification.error }, 400); } const user = await dbGet( (db as any).select().from(users).where(eq((users as any).id, verification.userId)) ); if (!user || user.accountStatus === 'suspended') { return c.json({ error: 'Invalid token' }, 400); } const authToken = await createToken(user.id, user.email, user.role); const refreshToken = await createRefreshToken(user.id); return c.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role, isClaimed: user.isClaimed, phone: user.phone, rucNumber: user.rucNumber, languagePreference: user.languagePreference, }, token: authToken, refreshToken, }); }); // Request password reset auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, email)) ); if (!user) { // Don't reveal if email exists return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); } if (user.accountStatus === 'suspended') { return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); } // Create reset token (expires in 30 minutes) const token = await createMagicLinkToken(user.id, 'reset_password', 30); const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`; // Send email try { await sendEmail({ to: email, subject: 'Reset Your Spanglish Password', html: `

Reset Your Password

Click the link below to reset your password. This link expires in 30 minutes.

Reset Password

Or copy this link: ${resetLink}

If you didn't request this, you can safely ignore this email.

`, }); } catch (error) { console.error('Failed to send password reset email:', error); } return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' }); }); // Reset password auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => { const { token, password } = c.req.valid('json'); // Validate password strength const passwordValidation = validatePassword(password); if (!passwordValidation.valid) { return c.json({ error: passwordValidation.error }, 400); } const verification = await verifyMagicLinkToken(token, 'reset_password'); if (!verification.valid) { return c.json({ error: verification.error }, 400); } const hashedPassword = await hashPassword(password); const now = getNow(); await (db as any) .update(users) .set({ password: hashedPassword, updatedAt: now, }) .where(eq((users as any).id, verification.userId)); // Invalidate all existing sessions for security await invalidateAllUserSessions(verification.userId!); return c.json({ message: 'Password reset successfully. Please log in with your new password.' }); }); // Claim unclaimed account auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => { const { email } = c.req.valid('json'); const user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, email)) ); if (!user) { return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); } if (user.isClaimed && user.accountStatus !== 'unclaimed') { return c.json({ error: 'Account is already claimed' }, 400); } // Create claim token (expires in 24 hours) const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60); const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`; // Send email try { await sendEmail({ to: email, subject: 'Claim Your Spanglish Account', html: `

Claim Your Account

An account was created for you during booking. Click below to set up your login credentials.

Claim Account

Or copy this link: ${claimLink}

This link expires in 24 hours.

`, }); } catch (error) { console.error('Failed to send claim account email:', error); } return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' }); }); // Complete account claim auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => { const { token, password, googleId } = c.req.valid('json'); if (!password && !googleId) { return c.json({ error: 'Please provide either a password or link a Google account' }, 400); } const verification = await verifyMagicLinkToken(token, 'claim_account'); if (!verification.valid) { return c.json({ error: verification.error }, 400); } const now = getNow(); const updates: Record = { isClaimed: toDbBool(true), accountStatus: 'active', updatedAt: now, }; if (password) { const passwordValidation = validatePassword(password); if (!passwordValidation.valid) { return c.json({ error: passwordValidation.error }, 400); } updates.password = await hashPassword(password); } if (googleId) { updates.googleId = googleId; } await (db as any) .update(users) .set(updates) .where(eq((users as any).id, verification.userId)); const user = await dbGet( (db as any).select().from(users).where(eq((users as any).id, verification.userId)) ); const authToken = await createToken(user.id, user.email, user.role); const refreshToken = await createRefreshToken(user.id); return c.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role, isClaimed: user.isClaimed, phone: user.phone, rucNumber: user.rucNumber, languagePreference: user.languagePreference, }, token: authToken, refreshToken, message: 'Account claimed successfully!', }); }); // Google OAuth login/register auth.post('/google', zValidator('json', googleAuthSchema), async (c) => { const { credential } = c.req.valid('json'); try { // Verify Google token // In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`); if (!response.ok) { return c.json({ error: 'Invalid Google token' }, 400); } const googleData = await response.json() as { sub: string; email: string; name: string; email_verified: string; }; if (googleData.email_verified !== 'true') { return c.json({ error: 'Google email not verified' }, 400); } const { sub: googleId, email, name } = googleData; // Check if user exists by email or google_id let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, email)) ); if (!user) { // Check by google_id user = await dbGet( (db as any).select().from(users).where(eq((users as any).googleId, googleId)) ); } const now = getNow(); if (user) { // User exists - link Google account if not already linked if (user.accountStatus === 'suspended') { return c.json({ error: 'Account is suspended. Please contact support.' }, 403); } if (!user.googleId) { await (db as any) .update(users) .set({ googleId, isClaimed: toDbBool(true), accountStatus: 'active', updatedAt: now, }) .where(eq((users as any).id, user.id)); } // Refresh user data user = await dbGet( (db as any).select().from(users).where(eq((users as any).id, user.id)) ); } else { // Create new user const firstUser = await isFirstUser(); const id = generateId(); const newUser = { id, email, password: null, name, phone: null, role: firstUser ? 'admin' : 'user', languagePreference: null, isClaimed: toDbBool(true), googleId, rucNumber: null, accountStatus: 'active', createdAt: now, updatedAt: now, }; await (db as any).insert(users).values(newUser); user = newUser; } const authToken = await createToken(user.id, user.email, user.role); const refreshToken = await createRefreshToken(user.id); return c.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role, isClaimed: user.isClaimed, phone: user.phone, rucNumber: user.rucNumber, languagePreference: user.languagePreference, }, token: authToken, refreshToken, }); } catch (error) { console.error('Google auth error:', error); return c.json({ error: 'Failed to authenticate with Google' }, 500); } }); // Get current user auth.get('/me', async (c) => { const user = await getAuthUser(c); if (!user) { return c.json({ error: 'Unauthorized' }, 401); } return c.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role, phone: user.phone, isClaimed: user.isClaimed, rucNumber: user.rucNumber, languagePreference: user.languagePreference, accountStatus: user.accountStatus, createdAt: user.createdAt, }, }); }); // Change password (authenticated users) auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => { const user = (c as any).get('user') as AuthUser; const { currentPassword, newPassword } = c.req.valid('json'); // Validate new password const passwordValidation = validatePassword(newPassword); if (!passwordValidation.valid) { return c.json({ error: passwordValidation.error }, 400); } // Verify current password if user has one if (user.password) { const validPassword = await verifyPassword(currentPassword, user.password); if (!validPassword) { return c.json({ error: 'Current password is incorrect' }, 400); } } const hashedPassword = await hashPassword(newPassword); const now = getNow(); await (db as any) .update(users) .set({ password: hashedPassword, updatedAt: now, }) .where(eq((users as any).id, user.id)); return c.json({ message: 'Password changed successfully' }); }); // Logout (client-side token removal, but we can log the action) auth.post('/logout', async (c) => { return c.json({ message: 'Logged out successfully' }); }); export default auth;