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 <moebest@outlook.jp>
This commit is contained in:
Han Yiming
2026-01-29 17:23:37 +08:00
committed by Paul Donald
parent 53137db0d4
commit 4a308bab37
9 changed files with 900 additions and 3 deletions

View File

@@ -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;
};

View 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 { revision as luciversion, branch as luciname } from 'luci.version';
import { default as LuCIRuntime } from 'luci.runtime'; import { default as LuCIRuntime } from 'luci.runtime';
import { urldecode } from 'luci.http'; import { urldecode } from 'luci.http';
import { get_challenges, verify } from 'luci.authplugins';
let ubus = connect(); let ubus = connect();
let uci = cursor(); let uci = cursor();
@@ -520,6 +521,15 @@ function session_setup(user, pass, path) {
closelog(); 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) { function check_authentication(method) {
let m = match(method, /^([[:alpha:]]+):(.+)$/); let m = match(method, /^([[:alpha:]]+):(.+)$/);
let sid; let sid;
@@ -936,6 +946,19 @@ dispatch = function(_http, path) {
pass = http.formvalue('luci_password'); 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) if (user != null && pass != null)
session = session_setup(user, pass, resolved.ctx.request_path); session = session_setup(user, pass, resolved.ctx.request_path);
@@ -945,7 +968,15 @@ dispatch = function(_http, path) {
http.status(403, 'Forbidden'); http.status(403, 'Forbidden');
http.header('X-LuCI-Login-Required', 'yes'); 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`; let theme_sysauth = `themes/${basename(runtime.env.media)}/sysauth`;
if (runtime.is_ucode_template(theme_sysauth) || runtime.is_lua_template(theme_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); 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', let cookie_name = (http.getenv('HTTPS') == 'on') ? 'sysauth_https' : 'sysauth_http',
cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : ''; cookie_secure = (http.getenv('HTTPS') == 'on') ? '; secure' : '';

View File

@@ -13,6 +13,12 @@
</div> </div>
{% endif %} {% endif %}
{% if (auth_message && !fuser): %}
<div class="alert-message">
<p>{{ auth_message }}</p>
</div>
{% endif %}
<div class="cbi-map"> <div class="cbi-map">
<h2 name="content">{{ _('Authorization Required') }}</h2> <h2 name="content">{{ _('Authorization Required') }}</h2>
<div class="cbi-map-descr"> <div class="cbi-map-descr">
@@ -31,6 +37,37 @@
<input class="cbi-input-text" type="password" name="luci_password" /> <input class="cbi-input-text" type="password" name="luci_password" />
</div> </div>
</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></div>
</div> </div>

View File

@@ -60,7 +60,9 @@
"description": "Grant access to Plugin management", "description": "Grant access to Plugin management",
"read": { "read": {
"file": { "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" ] "uci": [ "luci_plugins" ]
} }

View File

@@ -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

View File

@@ -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`

View File

@@ -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);
}
});

View File

@@ -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'
};
}
};

View File

@@ -21,13 +21,49 @@
<input name="luci_password" id="luci_password" type="password" autocomplete="current-password"> <input name="luci_password" id="luci_password" type="password" autocomplete="current-password">
</div> </div>
</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>
</div> </div>
</form> </form>
<hr> <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"> <div class="alert-message error">
{{ _('Invalid username and/or password! Please try again.') }} {{ _('Invalid username and/or password! Please try again.') }}
</div> </div>