Files
Kevin L'Hereec 991173aa05 feat(auth): add session login page, API key guard, and remove orphan SQL migrations
- Add session-based authentication for web-UI (Login.vue, AuthController)
- Add AuthSessionMiddleware: redirects unauthenticated users to /login
- Add ApiKeyMiddleware: validates X-Api-Key header on all /api/v1 routes
- Add BasicAuthMiddleware: registered for future M2M use
- Use timingSafeEqual for all credential comparisons (timing attack prevention)
- Regenerate session ID on login (session fixation prevention)
- Validate redirect target after login (open redirect prevention)
- Require WEBUI_USERNAME, WEBUI_PASSWORD, API_KEY as mandatory env vars
- Share auth.username/authenticated via Inertia shared props
- Add user dropdown in navbar (avatar icon + username + logout)
- Add login i18n keys in fr.json and en.json
- Remove orphan SQL migrations (users, auth_access_tokens) and @adonisjs/lucid
2026-07-03 13:59:05 +02:00

77 lines
2.6 KiB
TypeScript

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
}