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:
1 parent
198f15ec9b
commit
991173aa05
18 files changed
+477
-482
No files matched your search
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user