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 { 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' : '';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" ]
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user