import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { z } from 'zod'; import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { config } from '../config'; import { getDatabase } from '../database'; import { authenticate, AuthRequest } from '../middleware/auth'; import { authLimiter } from '../middleware/rateLimiter'; import { hashToken } from '../services/encryption'; import { sendVerificationEmail, sendPasswordResetEmail } from '../services/email'; const router = Router(); passport.use(new GoogleStrategy({ clientID: config.google.clientId, clientSecret: config.google.clientSecret, callbackURL: config.google.callbackUrl || `${config.serverUrl}/api/auth/google/callback`, scope: ['profile', 'email'], }, async (_accessToken, _refreshToken, profile, done) => { try { const db = getDatabase(); const email = profile.emails?.[0]?.value || `${profile.id}@google-oauth.local`; const googleId = profile.id; const displayName = profile.displayName || email.split('@')[0]; const avatarUrl = profile.photos?.[0]?.value || null; const existing = db.prepare('SELECT * FROM users WHERE google_id = ?').get(googleId) as any; let user = existing; if (!user) { const existingEmail = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any; if (existingEmail) { db.prepare('UPDATE users SET google_id = ?, avatar_url = COALESCE(?, avatar_url), updated_at = ? WHERE id = ?') .run(googleId, avatarUrl, Date.now(), existingEmail.id); user = db.prepare('SELECT * FROM users WHERE id = ?').get(existingEmail.id) as any; } } if (!user) { const id = uuidv4(); const now = Date.now(); let username = displayName.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase().slice(0, 30); const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existingUsername) { username = `${username}_${googleId.slice(0, 6)}`; } db.prepare(` INSERT INTO users (id, email, username, password_hash, verified, google_id, avatar_url, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?) `).run(id, email, username, 'google-auth', googleId, avatarUrl, now, now); const inserted = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as any; user = inserted; } done(null, user); } catch (err) { done(err as Error); } })); const registerSchema = z.object({ email: z.string().email(), username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_]+$/), password: z.string().min(8).max(128), }); const loginSchema = z.object({ email: z.string().email(), password: z.string().min(1), }); const forgotSchema = z.object({ email: z.string().email(), }); const resetSchema = z.object({ token: z.string().min(1), password: z.string().min(8).max(128), }); const verifySchema = z.object({ token: z.string().min(1), }); router.post('/register', authLimiter, async (req: Request, res: Response) => { try { const { email, username, password } = registerSchema.parse(req.body); const db = getDatabase(); const existing = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username); if (existing) { res.status(409).json({ error: 'Email or username already taken' }); return; } const id = uuidv4(); const passwordHash = await bcrypt.hash(password, 10); const verificationToken = uuidv4(); const now = Date.now(); db.prepare(` INSERT INTO users (id, email, username, password_hash, verified, verification_token, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?, ?) `).run(id, email, username, passwordHash, verificationToken, now, now); // Email is non-blocking - fire and forget sendVerificationEmail(email, verificationToken).catch(() => {}); const token = jwt.sign({ userId: id, email }, config.jwtSecret, { expiresIn: config.jwtExpiresIn, } as any); const ua = req.headers['user-agent'] || ''; const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser'; db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') .run(uuidv4(), id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now); res.status(201).json({ token, user: { id, email, username } }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input', details: error.errors }); return; } console.error('Register error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/login', authLimiter, async (req: Request, res: Response) => { try { const { email, password } = loginSchema.parse(req.body); const db = getDatabase(); const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email) as any; if (!user) { res.status(401).json({ error: 'Invalid email or password' }); return; } const valid = await bcrypt.compare(password, user.password_hash); if (!valid) { res.status(401).json({ error: 'Invalid email or password' }); return; } const now = Date.now(); const token = jwt.sign({ userId: user.id, email: user.email }, config.jwtSecret, { expiresIn: config.jwtExpiresIn, } as any); const ua = req.headers['user-agent'] || ''; const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser'; db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') .run(uuidv4(), user.id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now); res.json({ token, user: { id: user.id, email: user.email, username: user.username } }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input', details: error.errors }); return; } console.error('Login error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/logout', authenticate, (req: AuthRequest, res: Response) => { const db = getDatabase(); const authHeader = req.headers.authorization!; const token = authHeader.substring(7); db.prepare('DELETE FROM sessions WHERE token = ?').run(token); res.json({ message: 'Logged out successfully' }); }); router.post('/forgot-password', authLimiter, async (req: Request, res: Response) => { try { const { email } = forgotSchema.parse(req.body); const db = getDatabase(); const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email) as any; if (!user) { res.json({ message: 'If the email exists, a reset link has been sent' }); return; } const resetToken = uuidv4(); const resetTokenHash = hashToken(resetToken); const expiresAt = Date.now() + 60 * 60 * 1000; db.prepare('UPDATE users SET reset_token = ?, reset_token_expires = ? WHERE id = ?') .run(resetTokenHash, expiresAt, user.id); try { await sendPasswordResetEmail(email, resetToken); } catch { // Email failure logged already } res.json({ message: 'If the email exists, a reset link has been sent' }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input' }); return; } console.error('Forgot password error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/reset-password', async (req: Request, res: Response) => { try { const { token, password } = resetSchema.parse(req.body); const db = getDatabase(); const tokenHash = hashToken(token); const user = db.prepare( 'SELECT id FROM users WHERE reset_token = ? AND reset_token_expires > ?' ).get(tokenHash, Date.now()) as any; if (!user) { res.status(400).json({ error: 'Invalid or expired reset token' }); return; } const passwordHash = await bcrypt.hash(password, 10); const now = Date.now(); db.prepare( 'UPDATE users SET password_hash = ?, reset_token = NULL, reset_token_expires = NULL, updated_at = ? WHERE id = ?' ).run(passwordHash, now, user.id); db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id); res.json({ message: 'Password reset successfully' }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input' }); return; } console.error('Reset password error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.post('/verify-email', async (req: Request, res: Response) => { try { const { token } = verifySchema.parse(req.body); const db = getDatabase(); const result = db.prepare( 'UPDATE users SET verified = 1, verification_token = NULL WHERE verification_token = ?' ).run(token); if (result.changes === 0) { res.status(400).json({ error: 'Invalid verification token' }); return; } res.json({ message: 'Email verified successfully' }); } catch (error: any) { if (error instanceof z.ZodError) { res.status(400).json({ error: 'Invalid input' }); return; } console.error('Verify email error:', error); res.status(500).json({ error: 'Internal server error' }); } }); router.get('/google', passport.authenticate('google', { session: false })); const clientOrigin = process.env.SERVER_URL ? config.serverUrl : config.corsOrigin; router.get('/google/callback', passport.authenticate('google', { session: false, failureRedirect: `${clientOrigin}/login?error=google-auth-failed` }), (req: Request, res: Response) => { const user = (req as any).user; const token = jwt.sign({ userId: user.id, email: user.email }, config.jwtSecret, { expiresIn: config.jwtExpiresIn, } as any); const db = getDatabase(); const now = Date.now(); const ua = req.headers['user-agent'] || ''; const deviceInfo = ua.includes('Mobile') ? 'Mobile' : ua.includes('Chrome') ? 'Chrome' : ua.includes('Firefox') ? 'Firefox' : 'Browser'; db.prepare('INSERT INTO sessions (id, user_id, token, device_info, ip, user_agent, last_active, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)') .run(uuidv4(), user.id, token, deviceInfo, req.ip || '', ua, now, now + 7 * 24 * 60 * 60 * 1000, now); res.redirect(`${clientOrigin}/auth/callback?token=${token}`); } ); router.get('/me', authenticate, (req: AuthRequest, res: Response) => { const db = getDatabase(); const user = db.prepare('SELECT id, email, username, verified, created_at, password_hash, avatar_path FROM users WHERE id = ?') .get(req.userId) as any; if (!user) { res.status(404).json({ error: 'User not found' }); return; } res.json({ user: { id: user.id, email: user.email, username: user.username, verified: user.verified, created_at: user.created_at, hasPassword: user.password_hash !== 'google-auth', avatarPath: user.avatar_path || null, }, }); }); export default router;