Spaces:
Build error
Build error
| 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<boolean> { | |
| 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, | |
| }); | |
| }); | |
| } |