luci-base: add authentication plugin mechanism
authorHan Yiming <redacted>
Thu, 29 Jan 2026 09:23:37 +0000 (17:23 +0800)
committerPaul Donald <redacted>
Thu, 9 Apr 2026 12:26:09 +0000 (14:26 +0200)
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 <redacted>
modules/luci-base/ucode/authplugins.uc [new file with mode: 0644]
modules/luci-base/ucode/dispatcher.uc
modules/luci-base/ucode/template/sysauth.ut
modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json
plugins/luci-auth-example/Makefile [new file with mode: 0644]
plugins/luci-auth-example/README.md [new file with mode: 0644]
plugins/luci-auth-example/htdocs/luci-static/resources/view/plugins/d0ecde1b009d44ff82faa8b0ff219cef.js [new file with mode: 0644]
plugins/luci-auth-example/ucode/plugins/auth/login/d0ecde1b009d44ff82faa8b0ff219cef.uc [new file with mode: 0644]
themes/luci-theme-bootstrap/ucode/template/themes/bootstrap/sysauth.ut

diff --git a/modules/luci-base/ucode/authplugins.uc b/modules/luci-base/ucode/authplugins.uc
new file mode 100644 (file)
index 0000000..f81a221
--- /dev/null
@@ -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.<uuid>.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;
+};
index 0b31a38a10e0a6642575a257aeb9430a4cd43e1d..187b9167a0d245379cac3fb0bdd04391b2d693d1 100644 (file)
@@ -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' : '';
 
index babba2ecb522b7acec2e20e4ea071c15b74c307f..b3d625b39d40199b7cbc873af21b712c98c3f820 100644 (file)
                </div>
        {% endif %}
 
+       {% if (auth_message && !fuser): %}
+               <div class="alert-message">
+                       <p>{{ auth_message }}</p>
+               </div>
+       {% endif %}
+
        <div class="cbi-map">
                <h2 name="content">{{ _('Authorization Required') }}</h2>
                <div class="cbi-map-descr">
                                        <input class="cbi-input-text" type="password" name="luci_password" />
                                </div>
                        </div>
+                       {% if (auth_fields): %}
+                               {% for (let field in auth_fields): %}
+                                       <div class="cbi-value">
+                                               <label class="cbi-value-title">{{ _(field.label ?? field.name) }}</label>
+                                               <div class="cbi-value-field">
+                                                       <input class="cbi-input-text"
+                                                               type="{{ field.type ?? 'text' }}"
+                                                               name="{{ field.name }}"
+                                                               {% if (field.placeholder): %}placeholder="{{ field.placeholder }}"{% endif %}
+                                                               {% if (field.inputmode): %}inputmode="{{ field.inputmode }}"{% endif %}
+                                                               {% if (field.pattern): %}pattern="{{ field.pattern }}"{% endif %}
+                                                               {% if (field.maxlength): %}maxlength="{{ field.maxlength }}"{% endif %}
+                                                               {% if (field.autocomplete): %}autocomplete="{{ field.autocomplete }}"{% endif %}
+                                                               {% if (field.required): %}required{% endif %}
+                                                       />
+                                               </div>
+                                       </div>
+                               {% endfor %}
+                       {% endif %}
+                       {% if (auth_html): %}
+                               <div class="cbi-value">
+                                       {{ auth_html }}
+                               </div>
+                       {% endif %}
+                       {% if (auth_assets): %}
+                               {% for (let asset in auth_assets): %}
+                                       {% if (asset.type == 'script'): %}
+                                               <script src="{{ asset.src }}"></script>
+                                       {% endif %}
+                               {% endfor %}
+                       {% endif %}
                </div></div>
        </div>
 
index 82104a219c1002ea59056b27a62aa526633a8a19..c0899a20235b2fb1366211f1f62736d278030da3 100644 (file)
@@ -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 (file)
index 0000000..e0cb751
--- /dev/null
@@ -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 (file)
index 0000000..56bfbce
--- /dev/null
@@ -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/<uuid>.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/<uuid>.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.<uuid>.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/<plugin-uuid>/`
+- 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 (file)
index 0000000..1d9b184
--- /dev/null
@@ -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 (file)
index 0000000..0f8397b
--- /dev/null
@@ -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: '<div class="cbi-value-description">Example plugin challenge UI</div>',
+                       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'
+               };
+       }
+};
index e311f2d03fe28bccddf91c860ff66219b5415d50..210dadc4a6b4cfffaca5d625b18701ebfff9cf0c 100644 (file)
                                                <input name="luci_password" id="luci_password" type="password" autocomplete="current-password">
                                        </div>
                                </div>
+                               {% if (auth_fields): %}
+                                       {% for (let field in auth_fields): %}
+                                               <div class="cbi-value">
+                                                       <label class="cbi-value-title" for="{{ field.name }}">{{ _(field.label ?? field.name) }}</label>
+                                                       <div class="cbi-value-field">
+                                                               <input
+                                                                       name="{{ field.name }}"
+                                                                       id="{{ field.name }}"
+                                                                       type="{{ field.type ?? 'text' }}"
+                                                                       {% if (field.placeholder): %}placeholder="{{ field.placeholder }}"{% endif %}
+                                                                       {% if (field.inputmode): %}inputmode="{{ field.inputmode }}"{% endif %}
+                                                                       {% if (field.pattern): %}pattern="{{ field.pattern }}"{% endif %}
+                                                                       {% if (field.maxlength): %}maxlength="{{ field.maxlength }}"{% endif %}
+                                                                       {% if (field.autocomplete): %}autocomplete="{{ field.autocomplete }}"{% endif %}
+                                                                       {% if (field.required): %}required{% endif %}
+                                                               >
+                                                       </div>
+                                               </div>
+                                       {% endfor %}
+                               {% endif %}
+                               {% if (auth_html): %}
+                                       <div class="cbi-value">
+                                               {{ auth_html }}
+                                       </div>
+                               {% endif %}
+                               {% if (auth_assets): %}
+                                       {% for (let asset in auth_assets): %}
+                                               {% if (asset.type == 'script'): %}
+                                                       <script src="{{ asset.src }}"></script>
+                                               {% endif %}
+                                       {% endfor %}
+                               {% endif %}
                        </div>
                </div>
        </form>
 
        <hr>
 
-       {% if (fuser): %}
+       {% if (auth_message): %}
+       <div class="alert-message{% if (auth_plugin): %} warning{% endif %}">
+               {{ auth_message }}
+       </div>
+       {% elif (fuser): %}
        <div class="alert-message error">
                {{ _('Invalid username and/or password! Please try again.') }}
        </div>
git clone https://git.99rst.org/PROJECT