mirror of
https://github.com/openwrt/luci.git
synced 2026-04-15 10:51:51 +00:00
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:
400
modules/luci-base/ucode/authplugins.uc
Normal file
400
modules/luci-base/ucode/authplugins.uc
Normal 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;
|
||||
};
|
||||
@@ -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' : '';
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
</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">
|
||||
@@ -31,6 +37,37 @@
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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" ]
|
||||
}
|
||||
|
||||
11
plugins/luci-auth-example/Makefile
Normal file
11
plugins/luci-auth-example/Makefile
Normal 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
|
||||
164
plugins/luci-auth-example/README.md
Normal file
164
plugins/luci-auth-example/README.md
Normal 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`
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -21,13 +21,49 @@
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user