From 0f62fa64b8d97814e50188e6777f9d5ff90d2095 Mon Sep 17 00:00:00 2001 From: Han Yiming Date: Thu, 29 Jan 2026 17:23:37 +0800 Subject: [PATCH] luci-base: add authentication plugin mechanism This commit introduces a generic authentication plugin mechanism to the LuCI dispatcher, enabling multi-factor authentication (MFA/2FA) and other custom verification methods without modifying core files. This implementation integrates with the new plugin UI architecture introduced in commit 617f364 (luci-mod-system: implement plugin UI architecture), allowing authentication plugins to be managed through the unified System > Plugins interface. Signed-off-by: Han Yiming --- modules/luci-base/ucode/authplugins.uc | 400 ++++++++++++++++++ modules/luci-base/ucode/dispatcher.uc | 82 +++- modules/luci-base/ucode/template/sysauth.ut | 37 ++ .../usr/share/rpcd/acl.d/luci-mod-system.json | 4 +- plugins/luci-auth-example/Makefile | 11 + plugins/luci-auth-example/README.md | 164 +++++++ .../d0ecde1b009d44ff82faa8b0ff219cef.js | 73 ++++ .../login/d0ecde1b009d44ff82faa8b0ff219cef.uc | 94 ++++ .../template/themes/bootstrap/sysauth.ut | 38 +- 9 files changed, 900 insertions(+), 3 deletions(-) create mode 100644 modules/luci-base/ucode/authplugins.uc create mode 100644 plugins/luci-auth-example/Makefile create mode 100644 plugins/luci-auth-example/README.md create mode 100644 plugins/luci-auth-example/htdocs/luci-static/resources/view/plugins/d0ecde1b009d44ff82faa8b0ff219cef.js create mode 100644 plugins/luci-auth-example/ucode/plugins/auth/login/d0ecde1b009d44ff82faa8b0ff219cef.uc diff --git a/modules/luci-base/ucode/authplugins.uc b/modules/luci-base/ucode/authplugins.uc new file mode 100644 index 000000000000..f81a221fe4ef --- /dev/null +++ b/modules/luci-base/ucode/authplugins.uc @@ -0,0 +1,400 @@ +'use strict'; + +import { glob, basename, open, readfile, writefile } from 'fs'; +import { cursor } from 'uci'; +import { syslog, LOG_INFO, LOG_WARNING, LOG_AUTHPRIV } from 'log'; + +// Plugin cache +let auth_plugins = null; + +// Plugin path following master's plugin architecture +const PLUGIN_PATH = '/usr/share/ucode/luci/plugins/auth/login'; +const VERIFY_RATE_LIMIT_FILE = '/tmp/luci-auth-verify-rate-limit.json'; +const VERIFY_RATE_LIMIT_LOCK_FILE = '/tmp/luci-auth-verify-rate-limit.lock'; +const VERIFY_RATE_LIMIT_MAX_ATTEMPTS = 3; +const VERIFY_RATE_LIMIT_WINDOW = 30; +const VERIFY_RATE_LIMIT_LOCKOUT = 60; +const VERIFY_RATE_LIMIT_STALE = 86400; + +function verify_rate_limit_key(user, ip) { + return `${user || '?'}|${ip || '?'}`; +} + +function load_verify_rate_limit_state() { + let content = readfile(VERIFY_RATE_LIMIT_FILE); + let state = content ? json(content) : null; + + return type(state) == 'object' ? state : {}; +} + +function cleanup_verify_rate_limit_state(state, now) { + let keep_window = VERIFY_RATE_LIMIT_LOCKOUT; + if (keep_window < VERIFY_RATE_LIMIT_STALE) + keep_window = VERIFY_RATE_LIMIT_STALE; + + let stale_before = now - keep_window; + let cleaned = {}; + + for (let key, entry in state) { + if (type(entry) != 'object') + continue; + + let locked_until = int(entry.locked_until || 0); + let attempts = []; + + if (type(entry.attempts) == 'array') { + for (let attempt in entry.attempts) { + attempt = int(attempt); + if (attempt > (now - VERIFY_RATE_LIMIT_WINDOW)) + push(attempts, attempt); + } + } + + if (locked_until > now || length(attempts) > 0 || locked_until >= stale_before) + cleaned[key] = { attempts, locked_until }; + } + + return cleaned; +} + +function with_verify_rate_limit_state(cb) { + let lockfd = open(VERIFY_RATE_LIMIT_LOCK_FILE, 'w', 0600); + if (!lockfd || lockfd.lock('xn') !== true) { + lockfd?.close(); + return null; + } + + let now = time(); + let state = cleanup_verify_rate_limit_state(load_verify_rate_limit_state(), now); + let result = cb(state, now); + writefile(VERIFY_RATE_LIMIT_FILE, sprintf('%J', state)); + + lockfd.lock('u'); + lockfd.close(); + + return result; +} + +function check_verify_rate_limit(user, ip) { + let key = verify_rate_limit_key(user, ip); + let result = with_verify_rate_limit_state((state, now) => { + let entry = state[key]; + let locked_until = int(entry?.locked_until || 0); + + return { + limited: locked_until > now, + remaining: (locked_until > now) ? (locked_until - now) : 0 + }; + }); + + if (!result) { + syslog(LOG_WARNING|LOG_AUTHPRIV, 'luci: unable to read auth verify rate-limit state'); + return { limited: false, remaining: 0 }; + } + + return result; +} + +function note_verify_failure(user, ip) { + let key = verify_rate_limit_key(user, ip); + let result = with_verify_rate_limit_state((state, now) => { + let entry = state[key] || { attempts: [], locked_until: 0 }; + let locked_until = int(entry.locked_until || 0); + + if (locked_until > now) + return { limited: true, remaining: locked_until - now }; + + let attempts = []; + for (let attempt in entry.attempts) { + attempt = int(attempt); + if (attempt > (now - VERIFY_RATE_LIMIT_WINDOW)) + push(attempts, attempt); + } + + push(attempts, now); + + if (length(attempts) >= VERIFY_RATE_LIMIT_MAX_ATTEMPTS) { + locked_until = now + VERIFY_RATE_LIMIT_LOCKOUT; + state[key] = { attempts: [], locked_until }; + + return { limited: true, remaining: locked_until - now }; + } + + state[key] = { attempts, locked_until: 0 }; + return { limited: false, remaining: 0 }; + }); + + if (!result) { + syslog(LOG_WARNING|LOG_AUTHPRIV, 'luci: unable to write auth verify rate-limit state'); + return { limited: false, remaining: 0 }; + } + + return result; +} + +function clear_verify_rate_limit(user, ip) { + let key = verify_rate_limit_key(user, ip); + with_verify_rate_limit_state((state, now) => { + delete state[key]; + return true; + }); +} + +function normalize_assets(uuid, assets) { + let rv = []; + + if (type(assets) != 'array') + return rv; + + for (let asset in assets) { + let src = null; + + if (type(asset) == 'string') + src = asset; + else if (type(asset) == 'object' && type(asset.src) == 'string' && (asset.type == null || asset.type == 'script')) + src = asset.src; + + if (type(src) != 'string') + continue; + + if (!match(src, sprintf("^/luci-static/plugins/%s/", uuid))) + continue; + + if (match(src, /\.\.|[\r\n\t ]/)) + continue; + + push(rv, { type: 'script', src: src }); + } + + return rv; +} + +// Load all enabled authentication plugins. +// +// Plugins are loaded from PLUGIN_PATH and must: +// - Have a 32-character hex UUID filename (e.g., bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc) +// - Export a plugin object +// - Plugin object must have check(http, user) and verify(http, user) methods +// +// Configuration hierarchy: +// - luci_plugins.global.enabled = '1' +// - luci_plugins.global.auth_login_enabled = '1' +// - luci_plugins..enabled = '1' +// +// Returns array of loaded plugin objects +export function load() { + let uci = cursor(); + + // Check global plugin system enabled + if (uci.get("luci_plugins", "global", "enabled") != "1") + return []; + + // Check auth plugins class enabled + if (uci.get("luci_plugins", "global", "auth_login_enabled") != "1") + return []; + + // Return cached plugins if already loaded + if (auth_plugins != null) + return auth_plugins; + + auth_plugins = []; + + // Load auth plugins from plugin directory + for (let path in glob(PLUGIN_PATH + '/*.uc')) { + try { + let code = loadfile(path); + if (!code) + continue; + + let plugin = call(code); + if (type(plugin) != 'object') + continue; + + // Extract UUID from filename (32 char hex without dashes) + let filename = basename(path); + let uuid = replace(filename, /\.uc$/, ''); + + // Validate UUID format + if (!match(uuid, /^[a-f0-9]{32}$/)) + continue; + + // Check if this specific plugin is enabled + if (uci.get("luci_plugins", uuid, "enabled") != "1") + continue; + + // Validate plugin interface + if (type(plugin) == 'object' && + type(plugin.check) == 'function' && + type(plugin.verify) == 'function') { + + plugin.uuid = uuid; + plugin.name = uci.get("luci_plugins", uuid, "name") || uuid; + push(auth_plugins, plugin); + } + } + catch (e) { + syslog(LOG_WARNING, + sprintf("luci: failed to load auth plugin from %s: %s", path, e)); + } + } + + // Sort by priority (lower = first) + auth_plugins = sort(auth_plugins, (a, b) => (a.priority || 50) - (b.priority || 50)); + + return auth_plugins; +}; + +// Check if any plugin requires additional authentication. +// +// Iterates through enabled plugins and calls their check() method. +// Returns on first plugin that requires authentication. +// +// http - HTTP request object +// user - Username being authenticated +// +// Returns object with: +// pending - boolean, true if additional auth required +// plugin - the plugin requiring auth (if pending) +// fields - array of form fields to render (if pending) +// message - message to display (if pending) +export function get_challenges(http, user) { + let plugins = load(); + let challenges = []; + let fields = []; + let messages = []; + let html_parts = []; + let assets = []; + + for (let plugin in plugins) { + try { + let result = plugin.check(http, user); + if (result && result.required) { + push(challenges, { + uuid: plugin.uuid, + name: plugin.name, + priority: plugin.priority ?? 50, + fields: result.fields || [], + message: result.message || '', + html: result.html || null, + assets: normalize_assets(plugin.uuid, result.assets) + }); + } + } + catch (e) { + syslog(LOG_WARNING, + sprintf("luci: auth plugin '%s' check error: %s", plugin.name, e)); + } + } + + if (!length(challenges)) + return { pending: false, challenges: [] }; + + challenges = sort(challenges, (a, b) => a.priority - b.priority); + + for (let challenge in challenges) { + for (let field in challenge.fields) + push(fields, field); + + if (challenge.message) + push(messages, challenge.message); + + if (challenge.html) + push(html_parts, challenge.html); + + for (let asset in challenge.assets) + push(assets, asset); + } + + return { + pending: true, + challenges: challenges, + fields: fields, + message: length(messages) ? join(' ', messages) : 'Additional verification required', + html: length(html_parts) ? join('\n', html_parts) : null, + assets: assets + }; +}; + +// Verify user's response to authentication challenge. +// +// Iterates through enabled plugins and verifies each that requires auth. +// All requiring plugins must pass for verification to succeed. +// +// http - HTTP request object with form values +// user - Username being authenticated +// +// Returns object with: +// success - boolean, true if all verifications passed +// message - error message (if failed) +// plugin - the plugin that failed (if failed) +export function verify(http, user, required_plugins) { + let plugins = load(); + let plugin_map = {}; + let client_ip = http.getenv("REMOTE_ADDR") || "?"; + let rate_limit = check_verify_rate_limit(user, client_ip); + + if (type(required_plugins) != 'array') + return { success: false, message: 'Authentication plugin state missing' }; + + if (rate_limit.limited) + return { + success: false, + message: sprintf('Too many failed authentication attempts. Please try again in %d seconds.', rate_limit.remaining) + }; + + for (let plugin in plugins) + plugin_map[plugin.uuid] = plugin; + + for (let plugin_uuid in required_plugins) { + let plugin = plugin_map[plugin_uuid]; + + if (type(plugin) != 'object') { + syslog(LOG_WARNING, + sprintf("luci: auth plugin '%s' not loaded for verification", plugin_uuid)); + return { + success: false, + message: 'Authentication plugin unavailable' + }; + } + + try { + let verify_result = plugin.verify(http, user); + if (!(verify_result && verify_result.success)) { + let fail_limit = note_verify_failure(user, client_ip); + syslog(LOG_WARNING|LOG_AUTHPRIV, + sprintf("luci: auth plugin '%s' verification failed for %s from %s", + plugin.name, user || "?", http.getenv("REMOTE_ADDR") || "?")); + return { + success: false, + message: fail_limit.limited + ? sprintf('Too many failed authentication attempts. Please try again in %d seconds.', fail_limit.remaining) + : ((verify_result && verify_result.message) || 'Authentication failed'), + plugin: plugin + }; + } + + syslog(LOG_INFO|LOG_AUTHPRIV, + sprintf("luci: auth plugin '%s' verification succeeded for %s from %s", + plugin.name, user || "?", http.getenv("REMOTE_ADDR") || "?")); + } + catch (e) { + syslog(LOG_WARNING, + sprintf("luci: auth plugin '%s' verify error: %s", plugin.name, e)); + return { + success: false, + message: 'Authentication plugin error' + }; + } + } + + clear_verify_rate_limit(user, client_ip); + return { success: true }; +}; + +// Clear plugin cache. +// +// Call this if plugin configuration changes and you need +// to reload plugins without restarting uhttpd. +export function reset() { + auth_plugins = null; +}; diff --git a/modules/luci-base/ucode/dispatcher.uc b/modules/luci-base/ucode/dispatcher.uc index 0b31a38a10e0..187b9167a0d2 100644 --- a/modules/luci-base/ucode/dispatcher.uc +++ b/modules/luci-base/ucode/dispatcher.uc @@ -12,6 +12,7 @@ import { hash, load_catalog, change_catalog, translate, ntranslate, getuid } fro import { revision as luciversion, branch as luciname } from 'luci.version'; import { default as LuCIRuntime } from 'luci.runtime'; import { urldecode } from 'luci.http'; +import { get_challenges, verify } from 'luci.authplugins'; let ubus = connect(); let uci = cursor(); @@ -520,6 +521,15 @@ function session_setup(user, pass, path) { closelog(); } +function set_auth_required_plugins(session, plugin_ids) { + ubus.call("session", "set", { + ubus_rpc_session: session.sid, + values: { + pending_auth_plugins: (type(plugin_ids) == 'array') ? plugin_ids : null + } + }); +} + function check_authentication(method) { let m = match(method, /^([[:alpha:]]+):(.+)$/); let sid; @@ -936,6 +946,19 @@ dispatch = function(_http, path) { pass = http.formvalue('luci_password'); } + let auth_check = get_challenges(http, user ?? 'root'); + let auth_fields = null; + let auth_message = null; + let auth_html = null; + let auth_assets = null; + + if (auth_check.pending) { + auth_fields = auth_check.fields; + auth_message = auth_check.message; + auth_html = auth_check.html; + auth_assets = auth_check.assets; + } + if (user != null && pass != null) session = session_setup(user, pass, resolved.ctx.request_path); @@ -945,7 +968,15 @@ dispatch = function(_http, path) { http.status(403, 'Forbidden'); http.header('X-LuCI-Login-Required', 'yes'); - let scope = { duser: 'root', fuser: user }; + // Show login form with 2FA fields if required + let scope = { + duser: 'root', + fuser: user, + auth_fields: auth_fields, + auth_message: auth_message, + auth_html: auth_html, + auth_assets: auth_assets + }; let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`; if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) { @@ -960,6 +991,55 @@ dispatch = function(_http, path) { return runtime.render('sysauth', scope); } + let auth_user = session.data?.username; + if (!auth_user) + auth_user = user; + + // Compute required plugin list once for authenticated user and bind it to the temporary session. + auth_check = get_challenges(http, auth_user); + if (auth_check.pending) { + let required_plugin_ids = map(auth_check.challenges, c => c.uuid); + set_auth_required_plugins(session, required_plugin_ids); + + // Verify exactly the plugin list stored in this temporary session + let auth_verify = verify(http, auth_user, required_plugin_ids); + + if (!auth_verify.success) { + // Additional auth failed or not provided + // Destroy the temporary session to prevent bypass + ubus.call("session", "destroy", { ubus_rpc_session: session.sid }); + + resolved.ctx.path = []; + http.status(403, 'Forbidden'); + http.header('X-LuCI-Login-Required', 'yes'); + + let scope = { + duser: 'root', + fuser: user, + auth_plugin: length(auth_check.challenges) ? auth_check.challenges[0].name : null, + auth_fields: auth_check.fields, + auth_message: auth_verify.message ?? auth_check.message, + auth_html: auth_check.html, + auth_assets: auth_check.assets + }; + + let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`; + + if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_sysauth)) { + try { + return runtime.render(theme_sysauth, scope); + } + catch (e) { + runtime.env.media_error = `${e}`; + } + } + + return runtime.render('sysauth', scope); + } + + set_auth_required_plugins(session, null); + } + let cookie_name = (http.getenv('HTTPS') == 'on') ? 'sysauth_https' : 'sysauth_http', cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : ''; diff --git a/modules/luci-base/ucode/template/sysauth.ut b/modules/luci-base/ucode/template/sysauth.ut index babba2ecb522..b3d625b39d40 100644 --- a/modules/luci-base/ucode/template/sysauth.ut +++ b/modules/luci-base/ucode/template/sysauth.ut @@ -13,6 +13,12 @@ {% endif %} + {% if (auth_message && !fuser): %} +
+

{{ auth_message }}

+
+ {% endif %} +

{{ _('Authorization Required') }}

@@ -31,6 +37,37 @@
+ {% if (auth_fields): %} + {% for (let field in auth_fields): %} +
+ +
+ +
+
+ {% endfor %} + {% endif %} + {% if (auth_html): %} +
+ {{ auth_html }} +
+ {% endif %} + {% if (auth_assets): %} + {% for (let asset in auth_assets): %} + {% if (asset.type == 'script'): %} + + {% endif %} + {% endfor %} + {% endif %} diff --git a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json index 82104a219c10..c0899a20235b 100644 --- a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json +++ b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json @@ -60,7 +60,9 @@ "description": "Grant access to Plugin management", "read": { "file": { - "/usr/share/ucode/luci/*": [ "read" ] + "/usr/share/ucode/luci/*": [ "read" ], + "/www/luci-static/resources/view/plugins": [ "list" ], + "/www/luci-static/resources/view/plugins/*": [ "read" ] }, "uci": [ "luci_plugins" ] } diff --git a/plugins/luci-auth-example/Makefile b/plugins/luci-auth-example/Makefile new file mode 100644 index 000000000000..e0cb751f7b60 --- /dev/null +++ b/plugins/luci-auth-example/Makefile @@ -0,0 +1,11 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-auth-example +PKG_VERSION:=1.0 +PKG_RELEASE:=1 + +PKG_LICENSE:=Apache-2.0 + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/plugins/luci-auth-example/README.md b/plugins/luci-auth-example/README.md new file mode 100644 index 000000000000..56bfbce836d7 --- /dev/null +++ b/plugins/luci-auth-example/README.md @@ -0,0 +1,164 @@ +# LuCI Authentication Plugin Example + +This package demonstrates how to create authentication plugins for LuCI +that integrate with the plugin UI architecture (System > Plugins). + +## Architecture + +Authentication plugins consist of two components: + +### 1. Backend Plugin (ucode) +**Location**: `/usr/share/ucode/luci/plugins/auth/login/.uc` + +The backend plugin implements the authentication logic. It must: +- Return a plugin object +- Provide a `check(http, user)` method to determine if auth is required +- Provide a `verify(http, user)` method to validate the auth response +- Use a 32-character hexadecimal UUID as the filename + +**Example structure**: +```javascript +return { + priority: 10, // Optional: execution order (lower = first) + + check: function(http, user) { + // Return { required: true/false, fields: [...], message: '...', html: '...', assets: [...] } + }, + + verify: function(http, user) { + // Return { success: true/false, message: '...' } + } +}; +``` + +### 2. UI Plugin (JavaScript) +**Location**: `/www/luci-static/resources/view/plugins/.js` + +The UI plugin provides configuration interface in System > Plugins. It must: +- Extend `baseclass` +- Define `class: 'auth'` and `type: 'login'` +- Use the same UUID as the backend plugin (without .uc extension) +- Implement `addFormOptions(s)` to add configuration fields +- Optionally implement `configSummary(section)` to show current config + +**Example structure**: +```javascript +return baseclass.extend({ + class: 'auth', + class_i18n: _('Authentication'), + type: 'login', + type_i18n: _('Login'), + + id: 'd0ecde1b009d44ff82faa8b0ff219cef', + name: 'My Auth Plugin', + title: _('My Auth Plugin'), + description: _('Description of what this plugin does'), + + addFormOptions(s) { + // Add configuration options using form.* + }, + + configSummary(section) { + // Return summary string to display in plugin list + } +}); +``` + +## Configuration + +Plugins are configured through the `luci_plugins` UCI config: + +``` +config global 'global' + option enabled '1' # Global plugin system + option auth_login_enabled '1' # Auth plugin class + +config auth_login 'd0ecde1b009d44ff82faa8b0ff219cef' + option name 'Example Auth Plugin' + option enabled '1' + option priority '10' + option challenge_field 'verification_code' + option help_text 'Enter your code' + option test_code '123456' +``` + +## Integration with Login Flow + +1. User enters username/password +2. If password is correct, `check()` is called on each enabled auth plugin +3. If any plugin returns `required: true`, the login form shows additional fields + and optional raw HTML/JS assets +4. User submits the additional fields +5. `verify()` is called to validate the response +6. If verification succeeds, session is granted +7. If verification fails, user must try again + +The dispatcher stores the required plugin UUID list in session state before +verification, then clears it by setting `pending_auth_plugins` to `null` after +successful verification. + +Priority is configurable via `luci_plugins..priority` (lower values run first). +If changed at runtime, reload plugin cache or restart services to apply. + +## Raw HTML + JS Assets + +Plugins may return: + +- `html`: raw HTML snippet inserted into the login form +- `assets`: script URLs for challenge UI behavior + +Asset security rules: + +- URLs must be under `/luci-static/plugins//` +- Invalid asset URLs are ignored by the framework +- Keep `html` static or generated from trusted values only + +## Generating a UUID + +Use one of these methods: +```bash +# Linux +cat /proc/sys/kernel/random/uuid | tr -d '-' + +# macOS +uuidgen | tr -d '-' | tr '[:upper:]' '[:lower:]' + +# Online +# Visit https://www.uuidgenerator.net/ and remove dashes +``` + +## Plugin Types + +Common authentication plugin types: +- **TOTP/OTP**: Time-based one-time passwords (Google Authenticator, etc.) +- **SMS**: SMS verification codes +- **Email**: Email verification codes +- **WebAuthn**: FIDO2/WebAuthn hardware keys +- **Biometric**: Fingerprint, face recognition (mobile apps) +- **Push Notification**: Approve/deny on mobile device +- **Security Questions**: Additional security questions + +## Testing + +1. Install the plugin package +2. Navigate to System > Plugins +3. Enable "Global plugin system" +4. Enable "Authentication > Login" +5. Enable the specific auth plugin and configure it +6. Log out and try logging in +7. After entering correct password, you should see the auth challenge + +## Real Implementation Examples + +For production use, integrate with actual authentication systems: + +- **TOTP**: Use `oathtool` command or liboath library +- **SMS**: Integrate with SMS gateway API +- **WebAuthn**: Use WebAuthn JavaScript API and verify on server +- **LDAP 2FA**: Query LDAP server for 2FA attributes + +## See Also + +- LuCI Plugin Architecture: commit 617f364 +- HTTP Header Plugins: `plugins/plugins-example/` +- LuCI Dispatcher: `modules/luci-base/ucode/dispatcher.uc` diff --git a/plugins/luci-auth-example/htdocs/luci-static/resources/view/plugins/d0ecde1b009d44ff82faa8b0ff219cef.js b/plugins/luci-auth-example/htdocs/luci-static/resources/view/plugins/d0ecde1b009d44ff82faa8b0ff219cef.js new file mode 100644 index 000000000000..1d9b1841c670 --- /dev/null +++ b/plugins/luci-auth-example/htdocs/luci-static/resources/view/plugins/d0ecde1b009d44ff82faa8b0ff219cef.js @@ -0,0 +1,73 @@ +'use strict'; +'require baseclass'; +'require form'; + +/* +UI configuration for example authentication plugin. + +This file provides the configuration interface for the auth plugin +in System > Plugins. It defines the plugin metadata and configuration +options that will be stored in the luci_plugins UCI config. + +The filename must match the backend plugin UUID (32-char hex). +*/ + +return baseclass.extend({ + // Plugin classification + class: 'auth', + class_i18n: _('Authentication'), + + type: 'login', + type_i18n: _('Login'), + + // Plugin identity + name: 'Example Auth Plugin', + id: 'd0ecde1b009d44ff82faa8b0ff219cef', + title: _('Example Authentication Plugin'), + description: _('A simple example authentication plugin that demonstrates the auth plugin interface. ' + + 'This plugin adds a verification code challenge after password login.'), + + // Add configuration form options + addFormOptions(s) { + let o; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = o.disabled; + o.rmempty = false; + + o = s.option(form.Value, 'priority', _('Priority'), + _('Execution order. Lower values run first.')); + o.default = '10'; + o.datatype = 'integer'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'challenge_field', _('Challenge Field Name'), + _('The form field name for the verification code input.')); + o.default = 'verification_code'; + o.rmempty = false; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'help_text', _('Help Text'), + _('Text displayed to help users understand what to enter.')); + o.default = 'Enter your verification code'; + o.depends('enabled', '1'); + + o = s.option(form.Value, 'test_code', _('Test Code'), + _('For demonstration purposes, the expected verification code. ' + + 'In a real plugin, this would integrate with TOTP/SMS/WebAuthn systems.')); + o.default = '123456'; + o.password = true; + o.depends('enabled', '1'); + }, + + // Display current configuration summary + configSummary(section) { + if (section.enabled != '1') + return null; + + const challenge_field = section.challenge_field || 'verification_code'; + const help_text = section.help_text || 'Enter your verification code'; + + return _('Field: %s - %s').format(challenge_field, help_text); + } +}); diff --git a/plugins/luci-auth-example/ucode/plugins/auth/login/d0ecde1b009d44ff82faa8b0ff219cef.uc b/plugins/luci-auth-example/ucode/plugins/auth/login/d0ecde1b009d44ff82faa8b0ff219cef.uc new file mode 100644 index 000000000000..0f8397b17a5f --- /dev/null +++ b/plugins/luci-auth-example/ucode/plugins/auth/login/d0ecde1b009d44ff82faa8b0ff219cef.uc @@ -0,0 +1,94 @@ +/* +Example authentication plugin for LuCI +This plugin demonstrates the auth plugin interface. + +The plugin filename must be a 32-character UUID matching its JS config frontend. +This allows the plugin system to link backend behavior with user configuration. +*/ + +'use strict'; + +import { cursor } from 'uci'; + +/* +Auth plugins must return an object with: +- check(http, user): determines if authentication challenge is required +- verify(http, user): validates the user's authentication response +- priority (optional): execution order (lower = first, default 50) + +Authentication dispatcher behavior: +- Stores required plugin UUIDs in `pending_auth_plugins` before verification +- Clears `pending_auth_plugins` by setting it to `null` after success +*/ + +const uci_cursor = cursor(); +const plugin_uuid = 'd0ecde1b009d44ff82faa8b0ff219cef'; +const configured_priority = +(uci_cursor.get('luci_plugins', plugin_uuid, 'priority') ?? 10); +const plugin_priority = (configured_priority >= 0 && configured_priority <= 1000) ? configured_priority : 10; + +return { + // Optional priority for execution order (lower executes first) + priority: plugin_priority, + + // check() is called after successful password authentication + // to determine if additional verification is needed + check: function(http, user) { + // Get plugin config from luci_plugins + const enabled = uci_cursor.get('luci_plugins', plugin_uuid, 'enabled'); + + if (enabled != '1') + return { required: false }; + + // Check if user needs auth challenge + // This example always requires it when enabled + const challenge_field = uci_cursor.get('luci_plugins', plugin_uuid, 'challenge_field') || 'verification_code'; + const help_text = uci_cursor.get('luci_plugins', plugin_uuid, 'help_text') || 'Enter your verification code'; + + return { + required: true, + fields: [ + { + name: challenge_field, + label: 'Verification Code', + type: 'text', + placeholder: help_text + } + ], + message: 'Additional verification required', + html: '
Example plugin challenge UI
', + assets: [ + `/luci-static/plugins/${plugin_uuid}/challenge.js` + ] + }; + }, + + // verify() is called to validate the user's authentication response + verify: function(http, user) { + const challenge_field = uci_cursor.get('luci_plugins', plugin_uuid, 'challenge_field') || 'verification_code'; + const expected_code = uci_cursor.get('luci_plugins', plugin_uuid, 'test_code') || '123456'; + + // Get the submitted verification code + const submitted_code = http.formvalue(challenge_field); + + if (!submitted_code) { + return { + success: false, + message: 'Verification code is required' + }; + } + + // Simple example: check against configured test code + // Real implementations would check TOTP, SMS, WebAuthn, etc. + if (submitted_code == expected_code) { + return { + success: true, + message: 'Verification successful' + }; + } + + return { + success: false, + message: 'Invalid verification code' + }; + } +}; diff --git a/themes/luci-theme-bootstrap/ucode/template/themes/bootstrap/sysauth.ut b/themes/luci-theme-bootstrap/ucode/template/themes/bootstrap/sysauth.ut index e311f2d03fe2..210dadc4a6b4 100644 --- a/themes/luci-theme-bootstrap/ucode/template/themes/bootstrap/sysauth.ut +++ b/themes/luci-theme-bootstrap/ucode/template/themes/bootstrap/sysauth.ut @@ -21,13 +21,49 @@ + {% if (auth_fields): %} + {% for (let field in auth_fields): %} +
+ +
+ +
+
+ {% endfor %} + {% endif %} + {% if (auth_html): %} +
+ {{ auth_html }} +
+ {% endif %} + {% if (auth_assets): %} + {% for (let asset in auth_assets): %} + {% if (asset.type == 'script'): %} + + {% endif %} + {% endfor %} + {% endif %}
- {% if (fuser): %} + {% if (auth_message): %} +
+ {{ auth_message }} +
+ {% elif (fuser): %}
{{ _('Invalid username and/or password! Please try again.') }}