- 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
77 lines
2.6 KiB
TypeScript
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
|
|
}
|