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

+7
View File
@@ -29,3 +29,10 @@ LOG_PROTOCOL=udp4
# Security
HASH_KEY=0123456789abcdef0123456789abcdef
# Web UI Basic Auth (browser prompt)
WEBUI_USERNAME=Admin
WEBUI_PASSWORD=Grenoble:38
# REST API Key (set in X-Api-Key header)
API_KEY=change-me-with-a-strong-random-key
+76
View File
@@ -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
}
+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,
},
}
}
+12 -2
View File
@@ -1,4 +1,11 @@
import { Db, Collection, ObjectId, Filter, UpdateFilter, Sort } from 'mongodb'
import {
type Db,
type Collection,
ObjectId,
type Filter,
type UpdateFilter,
type Sort,
} from 'mongodb'
import { getMongoDb } from '#providers/mongodb_provider'
export abstract class BaseModel {
@@ -16,7 +23,10 @@ export abstract class BaseModel {
return this.getDb().collection(this.collectionName)
}
static async find(this: { collectionName: string; getDb(): Db }, filter: Filter<any> = {}): Promise<any[]> {
static async find(
this: { collectionName: string; getDb(): Db },
filter: Filter<any> = {}
): Promise<any[]> {
const collection = this.getDb().collection(this.collectionName)
return collection.find(filter).toArray()
}
@@ -1,21 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
@@ -1,31 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}
+76 -4
View File
@@ -1,12 +1,20 @@
<script setup lang="ts">
import { Link, Head, usePage } from '@inertiajs/vue3'
import { Link, Head, usePage, router } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
import { ref } from 'vue'
import { ref, computed } from 'vue'
const { t, locale } = useI18n()
const currentLocale = ref(locale.value)
const page = usePage()
const langMenuOpen = ref(false)
const userMenuOpen = ref(false)
function toggleUserMenu() {
userMenuOpen.value = !userMenuOpen.value
if (userMenuOpen.value) langMenuOpen.value = false
}
const auth = computed(() => (page.props as any).auth as { username: string | null; authenticated: boolean })
function switchLocale(lang: string) {
locale.value = lang
@@ -16,6 +24,11 @@ function switchLocale(lang: string) {
function toggleLangMenu() {
langMenuOpen.value = !langMenuOpen.value
if (langMenuOpen.value) userMenuOpen.value = false
}
function logout() {
router.post('/logout')
}
function isActive(paths: string | string[]): boolean {
@@ -116,9 +129,9 @@ function isActive(paths: string | string[]): boolean {
</li>
</ul>
<!-- Right: language dropdown -->
<!-- Right: language dropdown + logout -->
<div class="navbar-right">
<ul class="navbar-nav">
<ul class="navbar-nav navbar-nav_container">
<li class="nav-item dropdown" :class="{ show: langMenuOpen }">
<a
class="nav-link dropdown-toggle"
@@ -152,6 +165,34 @@ function isActive(paths: string | string[]): boolean {
</li>
</ul>
</li>
<!-- User dropdown -->
<li v-if="auth.authenticated" class="nav-item dropdown" :class="{ show: userMenuOpen }">
<button
type="button"
class="nav-link user-menu-toggle"
@click="toggleUserMenu"
:aria-expanded="userMenuOpen"
>
<!-- Person icon -->
<div class="mb-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</div>
<span class="user-name">{{ auth.username }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: userMenuOpen }">
<li>
<button type="button" class="dropdown-item" @click="logout">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true" class="me-2">
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5-5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
</svg>
{{ t('login.logout') }}
</button>
</li>
</ul>
</li>
</ul>
</div>
</nav>
@@ -228,6 +269,7 @@ main {
.navbar-right .navbar-nav {
display: flex;
flex-direction: row;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
@@ -249,4 +291,34 @@ main {
background-color: #ff7900;
color: #fff;
}
.user-menu-toggle {
background: none;
border: none;
padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);
color: rgba(255, 255, 255, 0.55);
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.user-menu-toggle svg {
display: block;
flex-shrink: 0;
}
.user-menu-toggle:hover,
.user-menu-toggle:focus {
color: rgba(255, 255, 255, 0.75);
outline: none;
}
.user-name {
line-height: 1;
}
</style>
+9
View File
@@ -249,5 +249,14 @@
"close": "Close",
"actions": "Actions",
"selected": "selected"
},
"login": {
"subtitle": "Auto-Configuration Server",
"username": "Username",
"password": "Password",
"signIn": "Sign in",
"signingIn": "Signing in…",
"invalidCredentials": "Invalid username or password.",
"logout": "Sign out"
}
}
+9
View File
@@ -249,5 +249,14 @@
"close": "Fermer",
"actions": "Actions",
"selected": "sélectionné(s)"
},
"login": {
"subtitle": "Auto-Configuration Server",
"username": "Identifiant",
"password": "Mot de passe",
"signIn": "Se connecter",
"signingIn": "Connexion…",
"invalidCredentials": "Identifiant ou mot de passe incorrect.",
"logout": "Se déconnecter"
}
}
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { useI18n } from 'vue-i18n'
// No layout — login page is standalone (no navbar/footer)
defineOptions({ layout: null })
const { t } = useI18n()
defineProps<{
error?: string | null
}>()
const form = useForm({
username: '',
password: '',
})
function submit() {
form.post('/login')
}
</script>
<template>
<div class="min-vh-100 d-flex flex-column align-items-center justify-content-center bg-light">
<div class="card shadow-sm" style="width: 100%; max-width: 400px">
<div class="card-body p-4">
<!-- Header -->
<div class="text-center mb-4">
<svg
width="48"
height="48"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
class="mb-3"
>
<rect width="100" height="100" fill="#ff7900" />
<rect x="14.29" y="71.43" width="71.43" height="14.29" fill="#fff" />
</svg>
<h1 class="h4 mb-0">ADAM ACS</h1>
<p class="text-muted small mt-1">{{ t('login.subtitle') }}</p>
</div>
<!-- Error alert -->
<div v-if="error" class="alert alert-danger" role="alert">
{{ t('login.invalidCredentials') }}
</div>
<!-- Login form -->
<form @submit.prevent="submit" novalidate>
<div class="mb-3">
<label for="username" class="form-label">{{ t('login.username') }}</label>
<input
id="username"
v-model="form.username"
type="text"
class="form-control"
:class="{ 'is-invalid': form.errors.username }"
autocomplete="username"
autofocus
required
/>
<div v-if="form.errors.username" class="invalid-feedback">
{{ form.errors.username }}
</div>
</div>
<div class="mb-4">
<label for="password" class="form-label">{{ t('login.password') }}</label>
<input
id="password"
v-model="form.password"
type="password"
class="form-control"
:class="{ 'is-invalid': form.errors.password }"
autocomplete="current-password"
required
/>
<div v-if="form.errors.password" class="invalid-feedback">
{{ form.errors.password }}
</div>
</div>
<button
type="submit"
class="btn btn-primary w-100"
:disabled="form.processing"
>
<span
v-if="form.processing"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
{{ form.processing ? t('login.signingIn') : t('login.signIn') }}
</button>
</form>
</div>
</div>
<footer class="mt-4 text-muted small">
&copy; Orange 2025
</footer>
</div>
</template>
+2 -404
View File
@@ -13,7 +13,6 @@
"@adonisjs/core": "^7.3.3",
"@adonisjs/cors": "^3.0.0",
"@adonisjs/inertia": "^4.2.0",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@adonisjs/static": "^2.0.1",
@@ -595,49 +594,6 @@
}
}
},
"node_modules/@adonisjs/lucid": {
"version": "22.4.2",
"resolved": "https://registry.npmjs.org/@adonisjs/lucid/-/lucid-22.4.2.tgz",
"integrity": "sha512-3ztENqBQIFSGAWREHKCGbL1g4ehtsxxsuFX72Uf5bq+oSc0D6UcumjzYpU8TPyBxhmgS6HuRSfz2rG42iuz7AA==",
"license": "MIT",
"dependencies": {
"@adonisjs/presets": "^3.0.0",
"@faker-js/faker": "^10.4.0",
"@poppinss/hooks": "^7.3.0",
"@poppinss/macroable": "^1.1.2",
"@poppinss/qs": "^6.15.0",
"@poppinss/utils": "^7.0.1",
"deepmerge": "^4.3.1",
"fast-deep-equal": "^3.1.3",
"igniculus": "^1.5.0",
"kleur": "^4.1.5",
"knex": "^3.2.8",
"knex-dynamic-connection": "^5.0.1",
"pretty-hrtime": "^1.0.3",
"slash": "^5.1.0",
"tarn": "^3.0.2"
},
"engines": {
"node": ">=24.0.0"
},
"peerDependencies": {
"@adonisjs/assembler": "^8.0.0-next.29 || ^8.0.0",
"@adonisjs/core": "^7.0.0-next.21 || ^7.0.0",
"@vinejs/vine": "^3.0.0 || ^4.0.0",
"luxon": "^3.4.4"
},
"peerDependenciesMeta": {
"@adonisjs/assembler": {
"optional": true
},
"@vinejs/vine": {
"optional": true
},
"luxon": {
"optional": true
}
}
},
"node_modules/@adonisjs/presets": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@adonisjs/presets/-/presets-3.0.0.tgz",
@@ -1375,22 +1331,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@faker-js/faker": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz",
"integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz",
@@ -6301,6 +6241,7 @@
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -6315,15 +6256,6 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/common-path-prefix": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
@@ -6504,15 +6436,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
@@ -7083,15 +7006,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -7787,15 +7701,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/get-port": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz",
@@ -7856,6 +7761,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==",
"dev": true,
"license": "MIT"
},
"node_modules/glob-parent": {
@@ -8098,15 +8004,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/igniculus": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/igniculus/-/igniculus-1.5.0.tgz",
"integrity": "sha512-vhj2J/cSzNg2G5tcK4Z1KZdeYmQa5keoxFULUYAxctK/zHJb1oraO7noCqnJxKe1b2eZdiiaSL1IHPOFAI8UYQ==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -8207,15 +8104,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/interpret": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -8241,21 +8129,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
@@ -8646,179 +8519,6 @@
"node": ">=6"
}
},
"node_modules/knex": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.2.10.tgz",
"integrity": "sha512-oypTHfrc9i72iyxaUQBKHOxhcr0xM65MPf6FpN02nimsftXwzXprIkLjfXdubvhbu4PMWLp023q8o8CYvHSuZw==",
"license": "MIT",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.18.1",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"pg-query-stream": "^4.14.0"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"pg-query-stream": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex-dynamic-connection": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/knex-dynamic-connection/-/knex-dynamic-connection-5.1.0.tgz",
"integrity": "sha512-kv41rybMmUKOEAxkGytKRITAthuHFzt0l5vRT1SuTOcve4a9JMc1t/ElVOTRlBEkBexWX8A5WcO71cufrqAxkw==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"knex": "3.2.7"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/knex-dynamic-connection/node_modules/knex": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.2.7.tgz",
"integrity": "sha512-VxdDE72x7Tc08E5yCu8HqYoeOm0HOjAraOtYiGSAUJTYkydwfSGBOpQqYHrzM5vjLNzw2JDL2vDH8m7DjIjtgA==",
"license": "MIT",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"pg-query-stream": "^4.14.0"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"pg-query-stream": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex-dynamic-connection/node_modules/knex/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/knex-dynamic-connection/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/knex/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/knex/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/ky": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
@@ -9150,12 +8850,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
@@ -9894,12 +9588,6 @@
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -9907,12 +9595,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
@@ -10514,18 +10196,6 @@
"node": ">= 12.13.0"
}
},
"node_modules/rechoir": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
"license": "MIT",
"dependencies": {
"resolve": "^1.20.0"
},
"engines": {
"node": ">= 10.13.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -10586,36 +10256,6 @@
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -11001,18 +10641,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/slashes": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz",
@@ -11338,18 +10966,6 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": {
"version": "5.55.10",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.10.tgz",
@@ -11406,15 +11022,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/tempura": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/tempura/-/tempura-0.4.1.tgz",
@@ -11454,15 +11061,6 @@
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tildify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/timekeeper": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/timekeeper/-/timekeeper-2.3.1.tgz",
-1
View File
@@ -71,7 +71,6 @@
"@adonisjs/core": "^7.3.3",
"@adonisjs/cors": "^3.0.0",
"@adonisjs/inertia": "^4.2.0",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@adonisjs/static": "^2.0.1",
+7
View File
@@ -43,6 +43,13 @@ export default await Env.create(new URL('../', import.meta.url), {
// Security
HASH_KEY: Env.schema.string.optional(),
// Web UI credentials (required — no hardcoded fallback)
WEBUI_USERNAME: Env.schema.string(),
WEBUI_PASSWORD: Env.schema.string(),
// REST API Key (required — must be set before boot)
API_KEY: Env.schema.string(),
// Exchange Store
EXCHANGE_STORE_MAX_MESSAGES: Env.schema.number.optional(),
EXCHANGE_STORE_TTL_SECONDS: Env.schema.number.optional(),
+6
View File
@@ -20,4 +20,10 @@ router.use([
export const middleware = router.named({
cwmpLogger: () => import('#middleware/cwmp_logger_middleware'),
cwmp: () => import('#middleware/cwmp_middleware'),
// basicAuth: available for machine-to-machine Basic Auth access if needed in the future
basicAuth: () => import('#middleware/basic_auth_middleware'),
// apiKey: protects all /api/v1 routes (X-Api-Key header)
apiKey: () => import('#middleware/api_key_middleware'),
// authSession: protects all web-ui routes (session-based login page)
authSession: () => import('#middleware/auth_session_middleware'),
})
+30 -19
View File
@@ -11,6 +11,7 @@ const PagesController = () => import('#controllers/pages_controller')
const CwmpController = () => import('#controllers/cwmp_controller')
const Tr069TraceController = () => import('#controllers/tr069_trace_controller')
const ExchangeController = () => import('#controllers/exchange_controller')
const AuthController = () => import('#controllers/auth_controller')
// CWMP/TR-069 endpoint (SOAP/XML) - uses dedicated middleware for XML parsing
router.post('/cwmp10', [CwmpController, 'handle']).use([middleware.cwmpLogger(), middleware.cwmp()])
@@ -20,7 +21,7 @@ router.get('/api/v1/checklife', [HealthController, 'checklife'])
router.get('/api/v1/checkservice', [HealthController, 'checkservice'])
router.get('/api/v1/health/tr069-exchanges', [HealthController, 'checkTr069Exchanges'])
// REST API routes
// REST API routes — protected by API key (X-Api-Key header)
router
.group(() => {
// Devices
@@ -81,23 +82,33 @@ router
router.delete('/firmware-catalog/routes/:id', [FirmwareCatalogController, 'deleteRoute'])
})
.prefix('/api/v1')
.use([middleware.apiKey()])
// Inertia page routes
router.get('/', [PagesController, 'home'])
router.get('/devices/:identifier', [PagesController, 'deviceDetail'])
router.post('/devices/:identifier/refresh', [PagesController, 'refreshDevice'])
router.post('/devices/:identifier/get-model', [PagesController, 'getModel'])
router.get('/commands', [PagesController, 'commands']).as('commands.page')
router.get('/commands/fleet', [PagesController, 'commandsFleet'])
router.get('/tr069-trace', [PagesController, 'tr069Trace'])
router.post('/commands/fleet', [PagesController, 'storeFleetDevices'])
router.post('/commands/fleet/command', [PagesController, 'storeFleetCommand'])
// Auth routes — public (no session guard)
router.get('/login', [AuthController, 'showLogin']).as('login')
router.post('/login', [AuthController, 'login'])
router.post('/logout', [AuthController, 'logout']).as('logout')
// Unified Configuration System Inertia routes
router.get('/configurations', [PagesController, 'configurationsList'])
router.get('/configurations/create', [PagesController, 'configurationCreate'])
router.get('/configurations/:id/edit', [PagesController, 'configurationEdit'])
router.get('/configurations/:id/detail', [PagesController, 'configurationDetail'])
router.get('/firmware-catalog', [PagesController, 'firmwareCatalog'])
router.get('/firmware-catalog/create', [PagesController, 'firmwareCatalogCreate'])
router.get('/firmware-catalog/:id/edit', [PagesController, 'firmwareCatalogEdit'])
// Inertia page routes — protected by session authentication
router
.group(() => {
router.get('/', [PagesController, 'home'])
router.get('/devices/:identifier', [PagesController, 'deviceDetail'])
router.post('/devices/:identifier/refresh', [PagesController, 'refreshDevice'])
router.post('/devices/:identifier/get-model', [PagesController, 'getModel'])
router.get('/commands', [PagesController, 'commands']).as('commands.page')
router.get('/commands/fleet', [PagesController, 'commandsFleet'])
router.get('/tr069-trace', [PagesController, 'tr069Trace'])
router.post('/commands/fleet', [PagesController, 'storeFleetDevices'])
router.post('/commands/fleet/command', [PagesController, 'storeFleetCommand'])
// Unified Configuration System Inertia routes
router.get('/configurations', [PagesController, 'configurationsList'])
router.get('/configurations/create', [PagesController, 'configurationCreate'])
router.get('/configurations/:id/edit', [PagesController, 'configurationEdit'])
router.get('/configurations/:id/detail', [PagesController, 'configurationDetail'])
router.get('/firmware-catalog', [PagesController, 'firmwareCatalog'])
router.get('/firmware-catalog/create', [PagesController, 'firmwareCatalogCreate'])
router.get('/firmware-catalog/:id/edit', [PagesController, 'firmwareCatalogEdit'])
})
.use([middleware.authSession()])