Spaces:
Sleeping
Sleeping
| 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; | |