diff --git a/.env.example b/.env.example index e10b6c1..b75923d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts new file mode 100644 index 0000000..6c2e85d --- /dev/null +++ b/app/controllers/auth_controller.ts @@ -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 +} diff --git a/app/middleware/api_key_middleware.ts b/app/middleware/api_key_middleware.ts new file mode 100644 index 0000000..b4d4d80 --- /dev/null +++ b/app/middleware/api_key_middleware.ts @@ -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() + } +} diff --git a/app/middleware/auth_session_middleware.ts b/app/middleware/auth_session_middleware.ts new file mode 100644 index 0000000..b1a8e55 --- /dev/null +++ b/app/middleware/auth_session_middleware.ts @@ -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() + } +} diff --git a/app/middleware/basic_auth_middleware.ts b/app/middleware/basic_auth_middleware.ts new file mode 100644 index 0000000..9a98f3c --- /dev/null +++ b/app/middleware/basic_auth_middleware.ts @@ -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)) +} diff --git a/app/middleware/inertia_middleware.ts b/app/middleware/inertia_middleware.ts index 2a4454f..477966d 100644 --- a/app/middleware/inertia_middleware.ts +++ b/app/middleware/inertia_middleware.ts @@ -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, + }, } } diff --git a/database/base_model.ts b/database/base_model.ts index 72f95d3..ef9894b 100644 --- a/database/base_model.ts +++ b/database/base_model.ts @@ -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 = {}): Promise { + static async find( + this: { collectionName: string; getDb(): Db }, + filter: Filter = {} + ): Promise { const collection = this.getDb().collection(this.collectionName) return collection.find(filter).toArray() } diff --git a/database/migrations/1761885935168_create_users_table.ts b/database/migrations/1761885935168_create_users_table.ts deleted file mode 100644 index dbca083..0000000 --- a/database/migrations/1761885935168_create_users_table.ts +++ /dev/null @@ -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) - } -} diff --git a/database/migrations/1768620764696_create_access_tokens_table.ts b/database/migrations/1768620764696_create_access_tokens_table.ts deleted file mode 100644 index a3ce197..0000000 --- a/database/migrations/1768620764696_create_access_tokens_table.ts +++ /dev/null @@ -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) - } -} diff --git a/inertia/layouts/MainLayout.vue b/inertia/layouts/MainLayout.vue index 87ee382..3762f24 100644 --- a/inertia/layouts/MainLayout.vue +++ b/inertia/layouts/MainLayout.vue @@ -1,12 +1,20 @@ + + diff --git a/package-lock.json b/package-lock.json index 9dafba8..66095d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3bdbff5..6b393ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/start/env.ts b/start/env.ts index 39c3c14..573cd37 100644 --- a/start/env.ts +++ b/start/env.ts @@ -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(), diff --git a/start/kernel.ts b/start/kernel.ts index 895c408..92f2ecd 100644 --- a/start/kernel.ts +++ b/start/kernel.ts @@ -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'), }) diff --git a/start/routes.ts b/start/routes.ts index bc5dc4e..002068f 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -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()])