--- /dev/null
+'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;
+};
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();
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;
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);
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)) {
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' : '';
</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>
"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" ]
}
--- /dev/null
+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
--- /dev/null
+# 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`
--- /dev/null
+'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);
+ }
+});
--- /dev/null
+/*
+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'
+ };
+ }
+};
<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>