From b1450cefa031198d924608bf09d136ddb5d52141 Mon Sep 17 00:00:00 2001 From: Han Yiming Date: Thu, 29 Jan 2026 16:37:38 +0800 Subject: [PATCH] luci-app-2fa: init checkin Co-authored-by: Christian Marangi Signed-off-by: Han Yiming luci-app-2fa: add priority option and QR code display This update adds a priority option and enables QR code display for 2FA. luci-app-2fa: native ubus IPvalid fsLOCK and log use native ubus IP validation instead of custom regex and parsing, use native fs lock instead of popen-call and add log for logging auth events. now, will clean stale rate limit entries on each check and log when entries are removed due to staleness. This prevents the rate limit file from growing indefinitely with old entries. luci-app-2fa: move dir and sync sysfixtime move to the new location. update the default time calibration threshold to sync sysfixtime. luci-app-2fa: native hex and more readable use native hex and base32 decoding functions Signed-off-by: Han Yiming --- plugins/luci-plugin-2fa/Makefile | 23 + plugins/luci-plugin-2fa/po/templates/2fa.pot | 196 +++++ .../root/etc/uci-defaults/luci-app-2fa | 44 ++ .../root/usr/libexec/generate_otp.uc | 125 +++ .../login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 720 ++++++++++++++++++ .../bb4ea47fcffb44ec9bb3d3673c9b4ed2.js | 191 +++++ 6 files changed, 1299 insertions(+) create mode 100644 plugins/luci-plugin-2fa/Makefile create mode 100644 plugins/luci-plugin-2fa/po/templates/2fa.pot create mode 100644 plugins/luci-plugin-2fa/root/etc/uci-defaults/luci-app-2fa create mode 100755 plugins/luci-plugin-2fa/root/usr/libexec/generate_otp.uc create mode 100644 plugins/luci-plugin-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc create mode 100644 plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js diff --git a/plugins/luci-plugin-2fa/Makefile b/plugins/luci-plugin-2fa/Makefile new file mode 100644 index 0000000000..993dbc1224 --- /dev/null +++ b/plugins/luci-plugin-2fa/Makefile @@ -0,0 +1,23 @@ +# +# Copyright (C) 2026 tokisaki galaxy +# Copyright (C) 2024 Christian Marangi +# +# This is free software, licensed under the Apache License, Version 2.0. +# + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-plugin-2fa + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=tokisaki galaxy +PKG_DESCRIPTION:=LuCI 2-Factor Authentication Plugin + +LUCI_TITLE:=LuCI 2-Factor Authentication +LUCI_DEPENDS:=+luci-base +luci-lib-uqr +ucode-mod-struct +ucode-mod-digest +ucode-mod-log +LUCI_PKGARCH:=all +LUCI_URL:=https://github.com/tokisaki-galaxy/luci-plugin-2fa + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot diff --git a/plugins/luci-plugin-2fa/po/templates/2fa.pot b/plugins/luci-plugin-2fa/po/templates/2fa.pot new file mode 100644 index 0000000000..190c71a408 --- /dev/null +++ b/plugins/luci-plugin-2fa/po/templates/2fa.pot @@ -0,0 +1,196 @@ +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 "" diff --git a/plugins/luci-plugin-2fa/root/etc/uci-defaults/luci-app-2fa b/plugins/luci-plugin-2fa/root/etc/uci-defaults/luci-app-2fa new file mode 100644 index 0000000000..d2bc594040 --- /dev/null +++ b/plugins/luci-plugin-2fa/root/etc/uci-defaults/luci-app-2fa @@ -0,0 +1,44 @@ +#!/bin/sh + +# luci-app-2fa: Setup script for two-factor authentication plugin +# This script sets up the 2FA plugin configuration in luci_plugins + +PLUGIN_UUID="bb4ea47fcffb44ec9bb3d3673c9b4ed2" + +# Ensure luci_plugins config file exists +touch /etc/config/luci_plugins + +# Create global section if not exists +uci -q get luci_plugins.global >/dev/null || { + uci set luci_plugins.global=global + uci set luci_plugins.global.enabled='0' +} + +# Enable auth_login plugins class if not set +uci -q get luci_plugins.global.auth_login_enabled >/dev/null || { + uci set luci_plugins.global.auth_login_enabled='0' +} + +# Create 2FA plugin section if not exists +uci -q get "luci_plugins.${PLUGIN_UUID}" >/dev/null || { + uci set "luci_plugins.${PLUGIN_UUID}=auth_login" + uci set "luci_plugins.${PLUGIN_UUID}.enabled=0" + uci set "luci_plugins.${PLUGIN_UUID}.name=Two-Factor Authentication" + + # Rate limiting defaults + uci set "luci_plugins.${PLUGIN_UUID}.rate_limit_enabled=1" + uci set "luci_plugins.${PLUGIN_UUID}.rate_limit_max_attempts=5" + uci set "luci_plugins.${PLUGIN_UUID}.rate_limit_window=60" + uci set "luci_plugins.${PLUGIN_UUID}.rate_limit_lockout=300" + + # Security defaults + uci set "luci_plugins.${PLUGIN_UUID}.strict_mode=0" + uci set "luci_plugins.${PLUGIN_UUID}.ip_whitelist_enabled=0" + + # Time calibration threshold (2026-01-01 00:00:00 UTC) + uci set "luci_plugins.${PLUGIN_UUID}.min_valid_time=1767225600" +} + +uci commit luci_plugins + +exit 0 diff --git a/plugins/luci-plugin-2fa/root/usr/libexec/generate_otp.uc b/plugins/luci-plugin-2fa/root/usr/libexec/generate_otp.uc new file mode 100755 index 0000000000..006e65d5b9 --- /dev/null +++ b/plugins/luci-plugin-2fa/root/usr/libexec/generate_otp.uc @@ -0,0 +1,125 @@ +#!/usr/bin/ucode + +// Copyright (c) 2024 Christian Marangi +// Copyright (c) 2026 tokiskai galaxy +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); diff --git a/plugins/luci-plugin-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/plugins/luci-plugin-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc new file mode 100644 index 0000000000..9af2d643ba --- /dev/null +++ b/plugins/luci-plugin-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -0,0 +1,720 @@ +// 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_, type_, step_, counter_ +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_) + 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_) + 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 }; + } +}; diff --git a/plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js b/plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js new file mode 100644 index 0000000000..b1547b046e --- /dev/null +++ b/plugins/luci-plugin-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js @@ -0,0 +1,191 @@ +'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'); + } +});