import { timingSafeEqual } from 'node:crypto' import type { HttpContext } from '@adonisjs/core/http' import env from '#start/env' /** * AuthController * * Handles web-ui login/logout via session. * Credentials are validated against WEBUI_USERNAME / WEBUI_PASSWORD env variables. * * Security: * - Constant-time comparison (timingSafeEqual) to prevent timing attacks * - Session regeneration after login to prevent session fixation * - Redirect target validated to prevent open redirect */ export default class AuthController { async showLogin({ inertia, session, response }: HttpContext) { // Already authenticated — redirect to home if (session.get('authenticated')) { return response.redirect().toPath('/') } const error = session.flashMessages.get('error') return (inertia as any).render('Login', { error }) } async login({ request, session, response }: HttpContext) { const { username, password } = request.only(['username', 'password']) const expectedUsername = env.get('WEBUI_USERNAME')! const expectedPassword = env.get('WEBUI_PASSWORD')! const usernameMatch = safeCompare(username ?? '', expectedUsername) const passwordMatch = safeCompare(password ?? '', expectedPassword) if (usernameMatch && passwordMatch) { // Regenerate session ID to prevent session fixation attacks session.regenerate() session.put('authenticated', true) session.put('username', username) // Validate redirect target to prevent open redirect const raw = session.get('redirectAfterLogin', '/') const redirectTo = isSafeRedirect(raw) ? raw : '/' session.forget('redirectAfterLogin') return response.redirect().toPath(redirectTo) } session.flash('error', 'invalid_credentials') return response.redirect().toPath('/login') } async logout({ session, response }: HttpContext) { session.clear() return response.redirect().toPath('/login') } } /** * Constant-time string comparison to prevent timing side-channel attacks. * Returns false immediately if lengths differ (length is not secret here). */ function safeCompare(a: string, b: string): boolean { if (a.length !== b.length) return false return timingSafeEqual(Buffer.from(a), Buffer.from(b)) } /** * Validate that a redirect path is a safe internal path. * Rejects empty strings, external URLs (//) and protocol-relative URLs. */ function isSafeRedirect(value: unknown): value is string { if (typeof value !== 'string') return false if (!value.startsWith('/')) return false if (value.startsWith('//')) return false return true }