- 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
73 lines
2.2 KiB
TypeScript
73 lines
2.2 KiB
TypeScript
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))
|
|
}
|