--- /dev/null
+msgid ""
+msgstr "Content-Type: text/plain; charset=UTF-8"
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:189
+msgid "2FA enabled"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:53
+msgid ""
+"Adds TOTP/HOTP verification as an additional authentication factor for LuCI "
+"login."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:150
+msgid "Advanced"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:153
+msgid "Allow bypassing 2FA from trusted IP addresses."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:45
+msgid "Authentication"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:112
+msgid "Authenticator QR Code"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:85
+msgid ""
+"Base32-encoded secret key for TOTP/HOTP. Generate using an authenticator app."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:59
+msgid "Basic Settings"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:145
+msgid ""
+"Block remote access when system time is not calibrated. LAN access is still "
+"allowed."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:75
+msgid ""
+"Configure 2FA keys for individual users. The key must be a Base32-encoded "
+"secret."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:61
+msgid "Enable 2FA"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:152
+msgid "Enable IP Whitelist"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:118
+msgid "Enable Rate Limiting"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:62
+msgid "Enable two-factor authentication for LuCI login."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:67
+msgid "Execution order for this plugin. Lower values run earlier."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:102
+msgid "HOTP (Counter-based)"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:138
+msgid "How long to lock out after too many failed attempts."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:158
+msgid "IP addresses or CIDR ranges that bypass 2FA. Example: 192.168.1.0/24"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:184
+msgid "IP whitelist on"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:94
+msgid "Invalid Base32 format. Use only A-Z and 2-7 characters."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:119
+msgid "Limit failed OTP attempts to prevent brute-force attacks."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:137
+msgid "Lockout Duration (seconds)"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:48
+msgid "Login"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:123
+msgid "Max Failed Attempts"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:124
+msgid "Maximum failed attempts before lockout."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:163
+msgid "Minimum Valid Time"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:98
+msgid "OTP Type for root"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:66
+msgid "Priority"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:130
+msgid "Rate Limit Window (seconds)"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:36
+msgid "Scan this QR code with your authenticator app."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:84
+msgid "Secret Key for root"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:116
+msgid "Security"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:14
+msgid "Set and save the secret key first to display a QR code."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:144
+msgid "Strict Mode"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:101
+msgid "TOTP (Time-based)"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:99
+msgid ""
+"TOTP (Time-based) is recommended. HOTP (Counter-based) is for special cases."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:105
+msgid "TOTP Time Step"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:106
+msgid "Time step in seconds for TOTP. Default is 30 seconds."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:131
+msgid "Time window for counting failed attempts."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:52
+msgid "Two-Factor Authentication"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:164
+msgid ""
+"Unix timestamp before which system time is considered uncalibrated. Default: "
+"2026-01-01."
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:74
+msgid "User Configuration"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:157
+msgid "Whitelisted IPs"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:181
+msgid "rate limiting on"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:178
+msgid "root user configured"
+msgstr ""
+
+#: plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:187
+msgid "strict mode"
+msgstr ""
--- /dev/null
+#!/usr/bin/ucode
+
+// Copyright (c) 2024 Christian Marangi <ansuelsmth@gmail.com>
+// Copyright (c) 2026 tokiskai galaxy <moebest@outlook.jp>
+import { cursor } from 'uci';
+import { sha1 } from 'digest';
+import { pack } from 'struct';
+
+const base32_decode_table = (function() {
+ let t = {};
+ for (let i = 0; i < 26; i++) { t[ord('A') + i] = i; t[ord('a') + i] = i; }
+ for (let i = 0; i < 6; i++) { t[ord('2') + i] = 26 + i; }
+ return t;
+})();
+
+function decode_base32_to_bin(string) {
+ let clean = replace(string, /[\s=]/g, "");
+ if (length(clean) == 0) return null;
+
+ let bin = "";
+ let buffer = 0;
+ let bits = 0;
+
+ for (let i = 0; i < length(clean); i++) {
+ let val = base32_decode_table[ord(clean, i)];
+ if (val === null || val === undefined) continue;
+
+ buffer = (buffer << 5) | val;
+ bits += 5;
+
+ if (bits >= 8) {
+ bits -= 8;
+ bin += chr((buffer >> bits) & 0xff);
+ }
+ }
+ return bin;
+}
+
+function calculate_hmac_sha1(key, message) {
+ const blocksize = 64;
+ if (length(key) > blocksize) key = hexdec(sha1(key));
+ while (length(key) < blocksize) key += chr(0);
+
+ let o_key_pad = "", i_key_pad = "";
+ for (let i = 0; i < blocksize; i++) {
+ let k = ord(key, i);
+ o_key_pad += chr(k ^ 0x5c);
+ i_key_pad += chr(k ^ 0x36);
+ }
+ let inner_hash = hexdec(sha1(i_key_pad + message));
+ return sha1(o_key_pad + inner_hash);
+}
+
+function calculate_otp(secret_base32, counter_int) {
+ let secret_bin = decode_base32_to_bin(secret_base32);
+ if (!secret_bin) return null;
+
+ let counter_bin = pack(">Q", counter_int);
+
+ let hmac_hex = calculate_hmac_sha1(secret_bin, counter_bin);
+
+ let offset = int(substr(hmac_hex, 38, 2), 16) & 0xf;
+ let binary_code = int(substr(hmac_hex, offset * 2, 8), 16) & 0x7fffffff;
+
+ return sprintf("%06d", binary_code % 1000000);
+}
+
+let username = ARGV[0];
+let no_increment = false;
+let custom_time = null;
+let plugin_uuid = null;
+
+for (let i = 1; i < length(ARGV); i++) {
+ let arg = ARGV[i];
+ if (arg == '--no-increment') {
+ no_increment = true;
+ } else if (substr(arg, 0, 7) == '--time=') {
+ let time_str = substr(arg, 7);
+ if (match(time_str, /^[0-9]+$/)) {
+ custom_time = int(time_str);
+ if (custom_time < 946684800 || custom_time > 4102444800) custom_time = null;
+ }
+ } else if (substr(arg, 0, 9) == '--plugin=') {
+ let uuid_str = substr(arg, 9);
+ if (match(uuid_str, /^[0-9a-fA-F]{32}$/)) plugin_uuid = uuid_str;
+ }
+}
+
+if (!username || username == '') exit(1);
+
+let ctx = cursor();
+let otp_type, secret, counter, step;
+
+if (plugin_uuid) {
+ otp_type = ctx.get('luci_plugins', plugin_uuid, 'type_' + username) || 'totp';
+ secret = ctx.get('luci_plugins', plugin_uuid, 'key_' + username);
+ counter = int(ctx.get('luci_plugins', plugin_uuid, 'counter_' + username) || '0');
+ step = int(ctx.get('luci_plugins', plugin_uuid, 'step_' + username) || '30');
+} else {
+ otp_type = ctx.get('2fa', username, 'type') || 'totp';
+ secret = ctx.get('2fa', username, 'key');
+ counter = int(ctx.get('2fa', username, 'counter') || '0');
+ step = int(ctx.get('2fa', username, 'step') || '30');
+}
+
+if (!secret) exit(1);
+
+let otp;
+if (otp_type == 'hotp') {
+ otp = calculate_otp(secret, counter);
+ if (!no_increment && otp) {
+ if (plugin_uuid) {
+ ctx.set('luci_plugins', plugin_uuid, 'counter_' + username, '' + (counter + 1));
+ ctx.commit('luci_plugins');
+ } else {
+ ctx.set('2fa', username, 'counter', '' + (counter + 1));
+ ctx.commit('2fa');
+ }
+ }
+} else {
+ let timestamp = (custom_time != null) ? custom_time : time();
+ otp = calculate_otp(secret, int(timestamp / step));
+}
+
+if (otp) print(otp); else exit(1);
--- /dev/null
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2026 LuCI 2FA Plugin Contributors
+//
+// LuCI Authentication Plugin: Two-Factor Authentication (2FA/OTP)
+//
+// This plugin implements TOTP/HOTP verification as an additional
+// authentication factor for LuCI login.
+//
+// Adapted for master's plugin architecture (luci_plugins UCI config)
+
+'use strict';
+
+import { popen, readfile, writefile, open } from 'fs';
+import { connect } from 'ubus';
+import { cursor } from 'uci';
+import { syslog, LOG_INFO, LOG_WARNING, LOG_AUTHPRIV } from 'log';
+
+const PLUGIN_UUID = 'bb4ea47fcffb44ec9bb3d3673c9b4ed2';
+
+// Default minimum valid time (2026-01-01 00:00:00 UTC)
+// TOTP depends on accurate system time. If system clock is not calibrated
+// (e.g., after power loss on devices without RTC battery), TOTP codes will
+// be incorrect and users will be locked out. This threshold disables TOTP
+// when system time appears uncalibrated.
+const DEFAULT_MIN_VALID_TIME = 1767225600;
+
+// Rate limit state file
+const RATE_LIMIT_FILE = '/tmp/2fa_rate_limit.json';
+const RATE_LIMIT_LOCK_FILE = '/tmp/2fa_rate_limit.lock';
+const DEFAULT_PRIORITY = 15;
+const RATE_LIMIT_STALE_SECONDS = 86400;
+let RATE_LIMIT_LOCK_HANDLE = null;
+let ubus = connect();
+
+function get_priority() {
+ let ctx = cursor();
+ let value = ctx.get('luci_plugins', PLUGIN_UUID, 'priority');
+
+ if (!value || !match(value, /^-?[0-9]+$/))
+ return DEFAULT_PRIORITY;
+
+ return int(value);
+}
+
+function get_system_min_valid_time_fallback() {
+ let newest = 0;
+ let fd = popen('find /etc -type f -exec date -r {} +%s \\; 2>/dev/null', 'r');
+ if (!fd)
+ return DEFAULT_MIN_VALID_TIME;
+
+ for (let line = fd.read('line'); line; line = fd.read('line')) {
+ line = trim(line);
+ if (!match(line, /^[0-9]+$/))
+ continue;
+
+ let ts = int(line);
+ if (ts > newest)
+ newest = ts;
+ }
+
+ fd.close();
+
+ return newest > 0 ? newest : DEFAULT_MIN_VALID_TIME;
+}
+
+// Check if system time is calibrated (not earlier than minimum valid time)
+function check_time_calibration() {
+ let ctx = cursor();
+ let config_time = ctx.get('luci_plugins', PLUGIN_UUID, 'min_valid_time');
+ let min_valid_time = config_time ? int(config_time) : get_system_min_valid_time_fallback();
+ let current_time = time();
+
+ return {
+ calibrated: current_time >= min_valid_time,
+ current_time: current_time,
+ min_valid_time: min_valid_time
+ };
+}
+
+// Constant-time string comparison to prevent timing attacks
+function constant_time_compare(a, b) {
+ if (length(a) != length(b))
+ return false;
+
+ let result = 0;
+ for (let i = 0; i < length(a); i++) {
+ result = result | (ord(a, i) ^ ord(b, i));
+ }
+ return result == 0;
+}
+
+// Sanitize username to prevent command injection
+function sanitize_username(username) {
+ if (!match(username, /^[a-zA-Z0-9_.+-]+$/))
+ return null;
+ return username;
+}
+
+// Validate IP address (IPv4 or IPv6)
+function is_valid_ip(ip) {
+ if (!ip || ip == '')
+ return false;
+
+ if (index(ip, '/') >= 0)
+ return parse_cidr(ip) != null;
+
+ return iptoarr(ip) != null;
+}
+
+function parse_cidr(cidr) {
+ let parts = split(cidr, '/');
+ if (length(parts) < 1 || length(parts) > 2)
+ return null;
+
+ let addr = iptoarr(parts[0]);
+ if (!addr)
+ return null;
+
+ let max_prefix = length(addr) * 8;
+ let prefix = max_prefix;
+
+ if (length(parts) == 2) {
+ if (!match(parts[1], /^[0-9]+$/))
+ return null;
+
+ prefix = int(parts[1]);
+ if (prefix < 0 || prefix > max_prefix)
+ return null;
+ }
+
+ return { addr, prefix };
+}
+
+function masked_bytes(bytes, prefix) {
+ let out = [];
+ let bits = prefix;
+
+ for (let b in bytes) {
+ if (bits >= 8) {
+ push(out, b);
+ bits -= 8;
+ }
+ else if (bits <= 0) {
+ push(out, 0);
+ }
+ else {
+ let mask = ((0xFF << (8 - bits)) & 0xFF);
+ push(out, b & mask);
+ bits = 0;
+ }
+ }
+
+ return out;
+}
+
+function matches_prefix(addr, network, prefix) {
+ if (length(addr) != length(network))
+ return false;
+
+ let a = masked_bytes(addr, prefix);
+ let n = masked_bytes(network, prefix);
+ for (let i = 0; i < length(a); i++) {
+ if (a[i] != n[i])
+ return false;
+ }
+
+ return true;
+}
+
+function netmask_to_prefix(mask) {
+ let prefix = 0;
+ let zero_seen = false;
+
+ for (let b in mask) {
+ for (let bit = 7; bit >= 0; bit--) {
+ if ((b & (1 << bit)) != 0) {
+ if (zero_seen)
+ return null;
+ prefix++;
+ }
+ else {
+ zero_seen = true;
+ }
+ }
+ }
+
+ return prefix;
+}
+
+function push_interface_subnets(subnets, addrs, expected_len, max_mask) {
+ if (type(addrs) != 'array')
+ return;
+
+ for (let addr in addrs) {
+ if (!addr.address || addr.mask == null)
+ continue;
+
+ let ip_addr = iptoarr(addr.address);
+ let mask = int(addr.mask);
+ if (ip_addr && length(ip_addr) == expected_len && mask >= 0 && mask <= max_mask)
+ push(subnets, arrtoip(masked_bytes(ip_addr, mask)) + '/' + mask);
+ }
+}
+
+// Check if an IP is in a CIDR range
+function ip_in_cidr(ip, cidr) {
+ let addr = iptoarr(ip);
+ let network = parse_cidr(cidr);
+ if (!addr || !network)
+ return false;
+
+ return matches_prefix(addr, network.addr, network.prefix);
+}
+
+// Check if IP is in whitelist
+function is_ip_whitelisted(ip) {
+ let ctx = cursor();
+
+ let whitelist_enabled = ctx.get('luci_plugins', PLUGIN_UUID, 'ip_whitelist_enabled');
+ if (whitelist_enabled != '1')
+ return false;
+
+ let settings = ctx.get_all('luci_plugins', PLUGIN_UUID);
+ if (!settings || !settings.ip_whitelist)
+ return false;
+
+ let ips = settings.ip_whitelist;
+ if (type(ips) == 'string') {
+ // Split space-separated string into array
+ ips = split(trim(ips), /\s+/);
+ }
+
+ for (let entry in ips) {
+ if (!entry || entry == '')
+ continue;
+ if (index(entry, '/') >= 0) {
+ if (ip_in_cidr(ip, entry))
+ return true;
+ } else {
+ if (ip == entry)
+ return true;
+ }
+ }
+
+ return false;
+}
+
+// Get all LAN interface subnets from OpenWrt network configuration
+function get_lan_subnets() {
+ let subnets = [];
+ let status = ubus?.call('network.interface.lan', 'status', {});
+ push_interface_subnets(subnets, status?.['ipv4-address'], 4, 32);
+ push_interface_subnets(subnets, status?.['ipv6-address'], 16, 128);
+
+ // Fallback to UCI network config
+ if (length(subnets) == 0) {
+ let ctx = cursor();
+ let lan_ipaddr = ctx.get('network', 'lan', 'ipaddr');
+ let lan_netmask = ctx.get('network', 'lan', 'netmask');
+
+ if (lan_ipaddr && lan_netmask) {
+ let ip_addr = iptoarr(lan_ipaddr);
+ let mask_addr = iptoarr(lan_netmask);
+ if (ip_addr && mask_addr && length(ip_addr) == 4 && length(mask_addr) == 4) {
+ let prefix = netmask_to_prefix(mask_addr);
+ if (prefix != null)
+ push(subnets, arrtoip(masked_bytes(ip_addr, prefix)) + '/' + prefix);
+ }
+ }
+
+ let lan_ip6addr = ctx.get('network', 'lan', 'ip6addr');
+ if (lan_ip6addr) {
+ let cidr = parse_cidr(lan_ip6addr);
+ if (cidr && length(cidr.addr) == 16)
+ push(subnets, arrtoip(masked_bytes(cidr.addr, cidr.prefix)) + '/' + cidr.prefix);
+ }
+ }
+
+ return subnets;
+}
+
+// Check if IP is in a LAN subnet
+function is_local_subnet(ip) {
+ if (!ip || ip == '')
+ return false;
+
+ let ip_addr = iptoarr(ip);
+ if (!ip_addr)
+ return false;
+
+ let lan_subnets = get_lan_subnets();
+
+ for (let subnet in lan_subnets) {
+ if (ip_in_cidr(ip, subnet))
+ return true;
+ }
+
+ return false;
+}
+
+// Load rate limit state
+function load_rate_limit_state() {
+ let content = readfile(RATE_LIMIT_FILE);
+ if (!content)
+ return {};
+
+ let state = json(content);
+ if (!state)
+ return {};
+
+ return state;
+}
+
+function cleanup_rate_limit_state(state, now, window, lockout) {
+ let changed = false;
+ let cleaned = {};
+ let min_attempt = now - window;
+ let keep_window = lockout;
+ if (keep_window < RATE_LIMIT_STALE_SECONDS)
+ keep_window = RATE_LIMIT_STALE_SECONDS;
+ let stale_before = now - keep_window;
+ let original_entries = 0;
+ let cleaned_entries = 0;
+
+ for (let ip, ip_state in state) {
+ original_entries++;
+
+ if (type(ip_state) != 'object') {
+ changed = true;
+ continue;
+ }
+
+ let locked_until = int(ip_state.locked_until || 0);
+ let attempts = [];
+
+ if (type(ip_state.attempts) == 'array') {
+ for (let attempt in ip_state.attempts) {
+ attempt = int(attempt);
+ if (attempt > min_attempt)
+ push(attempts, attempt);
+ }
+ }
+
+ if (locked_until > now || length(attempts) > 0) {
+ cleaned[ip] = { attempts, locked_until };
+ cleaned_entries++;
+ }
+ else if (locked_until < stale_before) {
+ changed = true;
+ }
+ }
+
+ if (cleaned_entries != original_entries)
+ changed = true;
+
+ return { state: cleaned, changed };
+}
+
+// Save rate limit state
+function save_rate_limit_state(state) {
+ writefile(RATE_LIMIT_FILE, sprintf('%J', state));
+}
+
+function lock_rate_limit_state() {
+ if (RATE_LIMIT_LOCK_HANDLE)
+ return true;
+
+ let fd = open(RATE_LIMIT_LOCK_FILE, 'w', 0600);
+ if (!fd)
+ return false;
+
+ if (fd.lock('xn') !== true) {
+ fd.close();
+ return false;
+ }
+
+ RATE_LIMIT_LOCK_HANDLE = fd;
+ return true;
+}
+
+function unlock_rate_limit_state() {
+ if (!RATE_LIMIT_LOCK_HANDLE)
+ return;
+
+ RATE_LIMIT_LOCK_HANDLE.lock('u');
+ RATE_LIMIT_LOCK_HANDLE.close();
+ RATE_LIMIT_LOCK_HANDLE = null;
+}
+
+function evaluate_rate_limit(ip, consume_attempt) {
+ let ctx = cursor();
+
+ let rate_limit_enabled = ctx.get('luci_plugins', PLUGIN_UUID, 'rate_limit_enabled');
+ if (rate_limit_enabled != '1')
+ return { allowed: true, remaining: -1, locked_until: 0 };
+
+ if (!lock_rate_limit_state())
+ return { allowed: false, remaining: 0, locked_until: time() + 5 };
+
+ let max_attempts = int(ctx.get('luci_plugins', PLUGIN_UUID, 'rate_limit_max_attempts') || '5');
+ let window = int(ctx.get('luci_plugins', PLUGIN_UUID, 'rate_limit_window') || '60');
+ let lockout = int(ctx.get('luci_plugins', PLUGIN_UUID, 'rate_limit_lockout') || '300');
+
+ let now = time();
+ let state = load_rate_limit_state();
+ let cleanup = cleanup_rate_limit_state(state, now, window, lockout);
+ state = cleanup.state;
+ if (cleanup.changed)
+ save_rate_limit_state(state);
+ let result;
+
+ if (!state[ip]) {
+ state[ip] = { attempts: [], locked_until: 0 };
+ }
+
+ let ip_state = state[ip];
+
+ if (ip_state.locked_until > now) {
+ result = { allowed: false, remaining: 0, locked_until: ip_state.locked_until };
+ unlock_rate_limit_state();
+ return result;
+ }
+
+ let recent_attempts = [];
+ for (let attempt in ip_state.attempts) {
+ if (attempt > (now - window))
+ push(recent_attempts, attempt);
+ }
+ ip_state.attempts = recent_attempts;
+
+ if (length(ip_state.attempts) >= max_attempts) {
+ ip_state.locked_until = now + lockout;
+ ip_state.attempts = [];
+ save_rate_limit_state(state);
+ result = { allowed: false, remaining: 0, locked_until: ip_state.locked_until };
+ unlock_rate_limit_state();
+ return result;
+ }
+
+ if (consume_attempt)
+ push(ip_state.attempts, now);
+
+ save_rate_limit_state(state);
+ result = { allowed: true, remaining: max_attempts - length(ip_state.attempts), locked_until: 0 };
+ unlock_rate_limit_state();
+ return result;
+}
+
+// Check rate limit
+function check_rate_limit(ip) {
+ return evaluate_rate_limit(ip, false);
+}
+
+// Reserve a rate-limit attempt atomically before verification
+function consume_rate_limit_attempt(ip) {
+ return evaluate_rate_limit(ip, true);
+}
+
+// Clear rate limit for an IP
+function clear_rate_limit(ip) {
+ if (!lock_rate_limit_state())
+ return;
+
+ let state = load_rate_limit_state();
+ if (state[ip]) {
+ delete state[ip];
+ save_rate_limit_state(state);
+ }
+
+ unlock_rate_limit_state();
+}
+
+// Check if 2FA is enabled for a user
+// Configuration keys: key_<username>, type_<username>, step_<username>, counter_<username>
+function is_2fa_enabled(username) {
+ let ctx = cursor();
+
+ // Check if plugin is enabled
+ let enabled = ctx.get('luci_plugins', PLUGIN_UUID, 'enabled');
+ if (enabled != '1')
+ return false;
+
+ let safe_username = sanitize_username(username);
+ if (!safe_username)
+ return false;
+
+ // Check if user has a key configured (key_<username>)
+ let key = ctx.get('luci_plugins', PLUGIN_UUID, 'key_' + safe_username);
+ if (!key || key == '')
+ return false;
+
+ return true;
+}
+
+// Verify OTP for user
+function verify_otp(username, otp) {
+ let ctx = cursor();
+
+ if (!otp || otp == '')
+ return { success: false };
+
+ let safe_username = sanitize_username(username);
+ if (!safe_username)
+ return { success: false };
+
+ otp = trim(otp);
+
+ if (!match(otp, /^[0-9]{6}$/))
+ return { success: false };
+
+ // Get OTP type (type_<username>)
+ let otp_type = ctx.get('luci_plugins', PLUGIN_UUID, 'type_' + safe_username) || 'totp';
+
+ if (otp_type == 'hotp') {
+ // HOTP verification
+ let fd = popen('/usr/libexec/generate_otp.uc ' + safe_username + ' --no-increment --plugin=' + PLUGIN_UUID, 'r');
+ if (!fd)
+ return { success: false };
+
+ let expected_otp = fd.read('all');
+ fd.close();
+ expected_otp = trim(expected_otp);
+
+ if (!match(expected_otp, /^[0-9]{6}$/))
+ return { success: false };
+
+ if (constant_time_compare(expected_otp, otp)) {
+ // OTP matches, increment the counter
+ let counter = int(ctx.get('luci_plugins', PLUGIN_UUID, 'counter_' + safe_username) || '0');
+ ctx.set('luci_plugins', PLUGIN_UUID, 'counter_' + safe_username, '' + (counter + 1));
+ ctx.commit('luci_plugins');
+ return { success: true };
+ }
+ return { success: false };
+ } else {
+ // TOTP verification
+ let step = int(ctx.get('luci_plugins', PLUGIN_UUID, 'step_' + safe_username) || '30');
+ if (step <= 0) step = 30;
+ let current_time = time();
+
+ // Check current window and adjacent windows
+ for (let offset in [0, -1, 1]) {
+ let check_time = int(current_time + (offset * step));
+ let fd = popen('/usr/libexec/generate_otp.uc ' + safe_username + ' --no-increment --time=' + check_time + ' --plugin=' + PLUGIN_UUID, 'r');
+ if (!fd)
+ continue;
+
+ let expected_otp = fd.read('all');
+ fd.close();
+ expected_otp = trim(expected_otp);
+
+ if (!match(expected_otp, /^[0-9]{6}$/))
+ continue;
+
+ if (constant_time_compare(expected_otp, otp)) {
+ return { success: true };
+ }
+ }
+ return { success: false };
+ }
+}
+
+// Get client IP from HTTP request
+function get_client_ip(http) {
+ let ip = null;
+
+ if (http && http.getenv) {
+ ip = http.getenv('REMOTE_ADDR');
+
+ if (ip && (ip == '127.0.0.1' || ip == '::1')) {
+ let xff = http.getenv('HTTP_X_FORWARDED_FOR');
+ if (xff) {
+ let parts = split(xff, ',');
+ ip = trim(parts[0]);
+ }
+ }
+ }
+
+ return ip || '';
+}
+
+return {
+ priority: get_priority(),
+
+ check: function(http, user) {
+ let client_ip = get_client_ip(http);
+
+ // Check if IP is whitelisted
+ if (client_ip && is_ip_whitelisted(client_ip)) {
+ return { required: false, whitelisted: true };
+ }
+
+ // Check rate limit
+ if (client_ip) {
+ let rate_check = check_rate_limit(client_ip);
+ if (!rate_check.allowed) {
+ let remaining_seconds = rate_check.locked_until - time();
+ return {
+ required: true,
+ blocked: true,
+ message: sprintf('Too many failed attempts. Please try again in %d seconds.', remaining_seconds),
+ fields: []
+ };
+ }
+ }
+
+ if (!is_2fa_enabled(user)) {
+ return { required: false };
+ }
+
+ // Check time calibration for TOTP
+ let ctx = cursor();
+ let safe_username = sanitize_username(user);
+ let otp_type = ctx.get('luci_plugins', PLUGIN_UUID, 'type_' + safe_username) || 'totp';
+
+ if (otp_type == 'totp') {
+ let time_check = check_time_calibration();
+ if (!time_check.calibrated) {
+ let strict_mode = ctx.get('luci_plugins', PLUGIN_UUID, 'strict_mode');
+
+ if (strict_mode == '1') {
+ if (client_ip && is_local_subnet(client_ip)) {
+ return { required: false, time_not_calibrated: true, local_subnet_bypass: true };
+ } else {
+ return {
+ required: true,
+ blocked: true,
+ message: 'System time is not calibrated. Login is blocked for security. Please access from LAN or sync system time.',
+ fields: []
+ };
+ }
+ } else {
+ return { required: false, time_not_calibrated: true };
+ }
+ }
+ }
+
+ return {
+ required: true,
+ fields: [
+ {
+ name: 'luci_otp',
+ type: 'text',
+ label: 'One-Time Password',
+ placeholder: '123456',
+ inputmode: 'numeric',
+ pattern: '[0-9]*',
+ maxlength: 6,
+ autocomplete: 'one-time-code',
+ required: true
+ }
+ ],
+ message: 'Please enter your one-time password from your authenticator app.'
+ };
+ },
+
+ verify: function(http, user) {
+ let client_ip = get_client_ip(http);
+
+ // Check if IP is whitelisted
+ if (client_ip && is_ip_whitelisted(client_ip)) {
+ syslog(LOG_INFO|LOG_AUTHPRIV,
+ sprintf("luci: 2FA bypassed for %s from %s due to IP whitelist",
+ user || '?', client_ip || '?'));
+ return { success: true, whitelisted: true };
+ }
+
+ // Reserve rate limit attempt atomically
+ if (client_ip) {
+ let rate_check = consume_rate_limit_attempt(client_ip);
+ if (!rate_check.allowed) {
+ let remaining_seconds = rate_check.locked_until - time();
+ syslog(LOG_WARNING|LOG_AUTHPRIV,
+ sprintf("luci: 2FA blocked for %s from %s due to rate limit (%d seconds remaining)",
+ user || '?', client_ip || '?', remaining_seconds));
+ return {
+ success: false,
+ rate_limited: true,
+ message: sprintf('Too many failed attempts. Please try again in %d seconds.', remaining_seconds)
+ };
+ }
+ }
+
+ let otp = http.formvalue('luci_otp');
+
+ if (otp)
+ otp = trim(otp);
+
+ if (!otp || otp == '') {
+ syslog(LOG_WARNING|LOG_AUTHPRIV,
+ sprintf("luci: 2FA verification failed for %s from %s due to missing OTP",
+ user || '?', client_ip || '?'));
+ return {
+ success: false,
+ message: 'Please enter your one-time password.'
+ };
+ }
+
+ let verify_result = verify_otp(user, otp);
+
+ if (!verify_result.success) {
+ syslog(LOG_WARNING|LOG_AUTHPRIV,
+ sprintf("luci: 2FA verification failed for %s from %s due to invalid OTP",
+ user || '?', client_ip || '?'));
+ return {
+ success: false,
+ message: 'Invalid one-time password. Please try again.'
+ };
+ }
+
+ // Clear rate limit on successful login
+ if (client_ip) clear_rate_limit(client_ip);
+
+ syslog(LOG_INFO|LOG_AUTHPRIV,
+ sprintf("luci: 2FA verification succeeded for %s from %s",
+ user || '?', client_ip || '?'));
+
+ return { success: true };
+ }
+};
--- /dev/null
+'use strict';
+'require baseclass';
+'require form';
+'require uci';
+'require rpc';
+'require uqr';
+
+var CBIQRCode = form.DummyValue.extend({
+ renderWidget(section_id) {
+ var key = uci.get('luci_plugins', section_id, 'key_root') || '';
+ var type = uci.get('luci_plugins', section_id, 'type_root') || 'totp';
+
+ if (!key)
+ return E('em', {}, _('Set and save the secret key first to display a QR code.'));
+
+ var issuer = 'OpenWrt';
+ var label = 'root';
+ var option;
+
+ if (type == 'hotp') {
+ var counter = uci.get('luci_plugins', section_id, 'counter_root') || '0';
+ option = 'counter=' + counter;
+ }
+ else {
+ var step = uci.get('luci_plugins', section_id, 'step_root') || '30';
+ option = 'period=' + step;
+ }
+
+ var otpAuth = 'otpauth://' + type + '/' + encodeURIComponent(issuer) + ':' + encodeURIComponent(label) +
+ '?secret=' + key + '&issuer=' + encodeURIComponent(issuer) + '&' + option;
+ var svg = uqr.renderSVG(otpAuth, { pixelSize: 4 });
+
+ return E('div', {}, [
+ E('div', { 'style': 'max-width:260px' }, [ E(svg) ]),
+ E('br'),
+ E('em', {}, _('Scan this QR code with your authenticator app.')),
+ E('br'),
+ E('code', { 'style': 'word-break:break-all;font-size:10px;' }, otpAuth)
+ ]);
+ }
+});
+
+return baseclass.extend({
+ class: 'auth',
+ class_i18n: _('Authentication'),
+
+ type: 'login',
+ type_i18n: _('Login'),
+
+ name: 'TOTP/HOTP 2FA',
+ id: 'bb4ea47fcffb44ec9bb3d3673c9b4ed2',
+ title: _('Two-Factor Authentication'),
+ description: _('Adds TOTP/HOTP verification as an additional authentication factor for LuCI login.'),
+
+ addFormOptions(s) {
+ let o;
+
+ // Tab: Basic Settings
+ s.tab('basic', _('Basic Settings'));
+
+ o = s.taboption('basic', form.Flag, 'enabled', _('Enable 2FA'),
+ _('Enable two-factor authentication for LuCI login.'));
+ o.default = o.disabled;
+ o.rmempty = false;
+
+ o = s.taboption('basic', form.Value, 'priority', _('Priority'),
+ _('Execution order for this plugin. Lower values run earlier.'));
+ o.depends('enabled', '1');
+ o.datatype = 'integer';
+ o.placeholder = '15';
+ o.rmempty = true;
+
+ // User configuration section
+ o = s.taboption('basic', form.SectionValue, '_users', form.TableSection, 'luci_plugins', _('User Configuration'),
+ _('Configure 2FA keys for individual users. The key must be a Base32-encoded secret.'));
+ o.depends('enabled', '1');
+
+ var ss = o.subsection;
+ ss.anonymous = true;
+ ss.addremove = false;
+ ss.nodescriptions = true;
+
+ // Since we can't easily enumerate users, provide a simple key configuration
+ o = s.taboption('basic', form.Value, 'key_root', _('Secret Key for root'),
+ _('Base32-encoded secret key for TOTP/HOTP. Generate using an authenticator app.'));
+ o.depends('enabled', '1');
+ o.password = true;
+ o.rmempty = true;
+ o.validate = function(section_id, value) {
+ if (!value || value === '')
+ return true;
+ // Validate Base32 format
+ if (!/^[A-Z2-7]+=*$/i.test(value.replace(/\s/g, '')))
+ return _('Invalid Base32 format. Use only A-Z and 2-7 characters.');
+ return true;
+ };
+
+ o = s.taboption('basic', form.ListValue, 'type_root', _('OTP Type for root'),
+ _('TOTP (Time-based) is recommended. HOTP (Counter-based) is for special cases.'));
+ o.depends('enabled', '1');
+ o.value('totp', _('TOTP (Time-based)'));
+ o.value('hotp', _('HOTP (Counter-based)'));
+ o.default = 'totp';
+
+ o = s.taboption('basic', form.Value, 'step_root', _('TOTP Time Step'),
+ _('Time step in seconds for TOTP. Default is 30 seconds.'));
+ o.depends({ 'enabled': '1', 'type_root': 'totp' });
+ o.placeholder = '30';
+ o.datatype = 'uinteger';
+ o.rmempty = true;
+
+ o = s.taboption('basic', CBIQRCode, '_qrcode', _('Authenticator QR Code'));
+ o.depends('enabled', '1');
+
+ // Tab: Security
+ s.tab('security', _('Security'));
+
+ o = s.taboption('security', form.Flag, 'rate_limit_enabled', _('Enable Rate Limiting'),
+ _('Limit failed OTP attempts to prevent brute-force attacks.'));
+ o.depends('enabled', '1');
+ o.default = '1';
+
+ o = s.taboption('security', form.Value, 'rate_limit_max_attempts', _('Max Failed Attempts'),
+ _('Maximum failed attempts before lockout.'));
+ o.depends('rate_limit_enabled', '1');
+ o.placeholder = '5';
+ o.datatype = 'uinteger';
+ o.rmempty = true;
+
+ o = s.taboption('security', form.Value, 'rate_limit_window', _('Rate Limit Window (seconds)'),
+ _('Time window for counting failed attempts.'));
+ o.depends('rate_limit_enabled', '1');
+ o.placeholder = '60';
+ o.datatype = 'uinteger';
+ o.rmempty = true;
+
+ o = s.taboption('security', form.Value, 'rate_limit_lockout', _('Lockout Duration (seconds)'),
+ _('How long to lock out after too many failed attempts.'));
+ o.depends('rate_limit_enabled', '1');
+ o.placeholder = '300';
+ o.datatype = 'uinteger';
+ o.rmempty = true;
+
+ o = s.taboption('security', form.Flag, 'strict_mode', _('Strict Mode'),
+ _('Block remote access when system time is not calibrated. LAN access is still allowed.'));
+ o.depends('enabled', '1');
+ o.default = o.disabled;
+
+ // Tab: Advanced
+ s.tab('advanced', _('Advanced'));
+
+ o = s.taboption('advanced', form.Flag, 'ip_whitelist_enabled', _('Enable IP Whitelist'),
+ _('Allow bypassing 2FA from trusted IP addresses.'));
+ o.depends('enabled', '1');
+ o.default = o.disabled;
+
+ o = s.taboption('advanced', form.DynamicList, 'ip_whitelist', _('Whitelisted IPs'),
+ _('IP addresses or CIDR ranges that bypass 2FA. Example: 192.168.1.0/24'));
+ o.depends('ip_whitelist_enabled', '1');
+ o.datatype = 'or(ip4addr, ip6addr, cidr4, cidr6)';
+ o.rmempty = true;
+
+ o = s.taboption('advanced', form.Value, 'min_valid_time', _('Minimum Valid Time'),
+ _('Unix timestamp before which system time is considered uncalibrated. Default: 2026-01-01.'));
+ o.depends('enabled', '1');
+ o.placeholder = '1767225600';
+ o.datatype = 'uinteger';
+ o.rmempty = true;
+ },
+
+ configSummary(section) {
+ if (section.enabled != '1')
+ return null;
+
+ var summary = [];
+
+ if (section.key_root)
+ summary.push(_('root user configured'));
+
+ if (section.rate_limit_enabled == '1')
+ summary.push(_('rate limiting on'));
+
+ if (section.ip_whitelist_enabled == '1')
+ summary.push(_('IP whitelist on'));
+
+ if (section.strict_mode == '1')
+ summary.push(_('strict mode'));
+
+ return summary.length ? summary.join(', ') : _('2FA enabled');
+ }
+});