Chat-With-AI / src /modules /auth /routes.ts
NathMen12's picture
Upload 56 files
ef73937 verified
Raw
History Blame Contribute Delete
8.84 kB
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,
});
});
}