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
This commit is contained in:
Kevin L'Hereec committed 2026-07-03 13:59:05 +02:00
1 parent 198f15ec9b
commit 991173aa05
18 files changed
+477 -482

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
import { timingSafeEqual } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import env from '#start/env'
/**
* ApiKeyMiddleware
*
* Protects REST API routes by validating a fixed API key provided in the
* X-Api-Key request header. The expected key is configured via:
* API_KEY (required env variable)
*
* Security:
* - Constant-time comparison (timingSafeEqual) to prevent timing attacks
*
* Responds with 401 if the header is missing or the key does not match.
*/
export default class ApiKeyMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
const apiKey = request.header('x-api-key') ?? ''
const expectedKey = env.get('API_KEY')!
const provided = Buffer.from(apiKey)
const expected = Buffer.from(expectedKey)
// Lengths must match and content must match in constant time
const valid =
provided.length === expected.length && timingSafeEqual(provided, expected)
if (!valid) {
return response.status(401).send('Unauthorized: invalid or missing X-Api-Key header')
}
return next()
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* AuthSessionMiddleware
*
* Protects web-ui routes by requiring an authenticated session.
* If the session is not authenticated, stores the requested URL
* in the session and redirects to /login.
*
* Login is handled by AuthController + session-based authentication.
*/
export default class AuthSessionMiddleware {
async handle({ session, request, response }: HttpContext, next: NextFn) {
if (!session.get('authenticated')) {
// Remember where the user wanted to go
if (request.method() === 'GET') {
session.put('redirectAfterLogin', request.url())
}
return response.redirect().toPath('/login')
}
return next()
}
}
+72
View File
@@ -0,0 +1,72 @@
import { timingSafeEqual } from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import env from '#start/env'
/**
* BasicAuthMiddleware
*
* Protects routes with HTTP Basic Authentication.
* Credentials are configured via environment variables:
* WEBUI_USERNAME
* WEBUI_PASSWORD
*
* Security:
* - Constant-time comparison (timingSafeEqual) to prevent timing attacks
*
* Responds with 401 + WWW-Authenticate header if credentials are missing or invalid.
*
* NOTE: This middleware is registered for potential future use (e.g. machine-to-machine
* access to web-ui pages). Web-UI currently uses session-based auth (authSession).
*/
export default class BasicAuthMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
const authHeader = request.header('authorization') || ''
if (!authHeader.startsWith('Basic ')) {
return this.#unauthorized(response)
}
const base64Credentials = authHeader.slice(6)
let decoded: string
try {
decoded = Buffer.from(base64Credentials, 'base64').toString('utf-8')
} catch {
return this.#unauthorized(response)
}
// Split only on the first colon — password may contain colons
const colonIndex = decoded.indexOf(':')
if (colonIndex === -1) {
return this.#unauthorized(response)
}
const username = decoded.slice(0, colonIndex)
const password = decoded.slice(colonIndex + 1)
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) {
return this.#unauthorized(response)
}
return next()
}
#unauthorized(response: HttpContext['response']) {
response.header('WWW-Authenticate', 'Basic realm="ADAM ACS"')
return response.status(401).send('Unauthorized')
}
}
/**
* Constant-time string comparison to prevent timing side-channel attacks.
*/
function safeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
+4
View File
@@ -6,6 +6,10 @@ export default class InertiaMiddleware extends BaseInertiaMiddleware {
async share(ctx: HttpContext) {
return {
errors: ctx.session?.flashMessages.get('errors'),
auth: {
username: ctx.session?.get('username') ?? null,
authenticated: ctx.session?.get('authenticated') ?? false,
},
}
}