import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { userRepository } from '@core/storage/repositories/users.js'; import { sessionRepository } from '@core/storage/repositories/sessions.js'; import { logRepository } from '@core/storage/repositories/logs.js'; import { hashPassword, verifyPassword, validatePasswordStrength } from '@core/auth/password.js'; import { createUserSession, destroySession } from '@core/auth/session.js'; import { validateInput, registerSchema, loginSchema } from '@core/security/validation.js'; import { config } from '@config/index.js'; import { getClientIP } from '@core/security/vpnDetect.js'; import { nanoid } from 'nanoid'; interface CaptchaResponse { success: boolean; score?: number; 'error-codes'?: string[]; } async function verifyCaptcha(token: string, ip: string): Promise { if (config.CAPTCHA_PROVIDER === 'turnstile') { const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: config.CAPTCHA_SECRET_KEY!, response: token, remoteip: ip, }), }); const data = await response.json() as CaptchaResponse; return data.success; } else { const response = await fetch('https://hcaptcha.com/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ secret: config.CAPTCHA_SECRET_KEY!, response: token, remoteip: ip, }), }); const data = await response.json() as CaptchaResponse; return data.success; } } export async function authRoutes(app: FastifyInstance) { // Register page app.get('/register', async (request, reply) => { return reply.view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, }); }); // Register handler app.post('/register', async (request, reply) => { const ip = getClientIP(request); const validation = validateInput(registerSchema, request.body); if (!validation.success) { return reply.status(400).view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: validation.errors.map(e => e.message), formData: request.body, }); } const { username, password, captchaToken, acceptTerms } = validation.data; // Verify captcha const captchaValid = await verifyCaptcha(captchaToken, ip); if (!captchaValid) { return reply.status(400).view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Captcha verification failed. Please try again.'], formData: { username }, }); } // Check if registration is enabled const regEnabled = await (await import('@core/storage/repositories/settings.js')).settingsRepository.get('registration_enabled'); if (regEnabled === false) { return reply.status(403).view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Registration is currently disabled'], }); } // Check existing user const existingUser = await userRepository.findByUsername(username); if (existingUser) { return reply.status(400).view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Username already taken'], formData: { username }, }); } // Validate password strength const pwdCheck = validatePasswordStrength(password); if (!pwdCheck.valid) { return reply.status(400).view('pages/auth/register.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: pwdCheck.errors, formData: { username }, }); } // Hash password and create user const passwordHash = await hashPassword(password); const user = await userRepository.createUser(username, passwordHash); // Create session const sessionId = await createUserSession(user.id); // Log await logRepository.log({ level: 'info', message: 'User registered', context: { username, isFirstUser: user.isAdmin }, userId: user.id, ip, userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); // Set session cookie reply.setCookie('session_id', sessionId, { httpOnly: true, secure: config.NODE_ENV === 'production', sameSite: 'lax', maxAge: config.sessionTtlMs / 1000, path: '/', }); return reply.redirect('/'); }); // Login page app.get('/login', async (request, reply) => { return reply.view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, }); }); // Login handler app.post('/login', async (request, reply) => { const ip = getClientIP(request); const validation = validateInput(loginSchema, request.body); if (!validation.success) { return reply.status(400).view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: validation.errors.map(e => e.message), formData: request.body, }); } const { username, password, captchaToken } = validation.data; // Verify captcha const captchaValid = await verifyCaptcha(captchaToken, ip); if (!captchaValid) { return reply.status(400).view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Captcha verification failed. Please try again.'], formData: { username }, }); } // Find user const user = await userRepository.findByUsername(username); if (!user) { return reply.status(400).view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Invalid username or password'], formData: { username }, }); } if (user.isBanned) { return reply.status(403).view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: [`Account banned: ${user.banReason}`], }); } // Verify password const validPassword = await verifyPassword(user.passwordHash, password); if (!validPassword) { return reply.status(400).view('pages/auth/login.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, errors: ['Invalid username or password'], formData: { username }, }); } // Create session const sessionId = await createUserSession(user.id); await userRepository.setLastLogin(user.id); // Log await logRepository.log({ level: 'info', message: 'User logged in', context: { username }, userId: user.id, ip, userAgent: request.headers['user-agent'], url: request.url, method: request.method, }); // Set session cookie reply.setCookie('session_id', sessionId, { httpOnly: true, secure: config.NODE_ENV === 'production', sameSite: 'lax', maxAge: config.sessionTtlMs / 1000, path: '/', }); return reply.redirect('/'); }); // Logout app.post('/logout', async (request, reply) => { const sessionId = request.cookies?.session_id; if (sessionId) { await destroySession(sessionId); } reply.clearCookie('session_id', { path: '/' }); return reply.redirect('/'); }); // Forgot password (placeholder) app.get('/forgot-password', async (request, reply) => { return reply.view('pages/auth/forgot-password.njk', { csrfToken: request.csrfToken(), captchaSiteKey: config.CAPTCHA_SITE_KEY, captchaProvider: config.CAPTCHA_PROVIDER, }); }); }