Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions plugins/luci-plugin-2fa/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#
# Copyright (C) 2026 tokisaki galaxy <moebest@outlook.jp>
# Copyright (C) 2024 Christian Marangi <ansuelsmth@gmail.com>
#
# 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 <moebest@outlook.jp>
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
196 changes: 196 additions & 0 deletions plugins/luci-plugin-2fa/po/templates/2fa.pot
Original file line number Diff line number Diff line change
@@ -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 ""
44 changes: 44 additions & 0 deletions plugins/luci-plugin-2fa/root/etc/uci-defaults/luci-app-2fa
Original file line number Diff line number Diff line change
@@ -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
125 changes: 125 additions & 0 deletions plugins/luci-plugin-2fa/root/usr/libexec/generate_otp.uc
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/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);
Loading
Loading