Skip to content
Draft
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-app-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-app-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-app-2fa

include ../../luci.mk

# call BuildPackage - OpenWrt buildroot
176 changes: 176 additions & 0 deletions plugins/luci-app-2fa/po/templates/2fa.pot
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:149
msgid "2FA enabled"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:23
msgid ""
"Adds TOTP/HOTP verification as an additional authentication factor for LuCI "
"login."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:110
msgid "Advanced"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:113
msgid "Allow bypassing 2FA from trusted IP addresses."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:15
msgid "Authentication"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:48
msgid ""
"Base32-encoded secret key for TOTP/HOTP. Generate using an authenticator app."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:29
msgid "Basic Settings"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:105
msgid ""
"Block remote access when system time is not calibrated. LAN access is still "
"allowed."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:38
msgid ""
"Configure 2FA keys for individual users. The key must be a Base32-encoded "
"secret."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:31
msgid "Enable 2FA"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:112
msgid "Enable IP Whitelist"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:78
msgid "Enable Rate Limiting"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:32
msgid "Enable two-factor authentication for LuCI login."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:65
msgid "HOTP (Counter-based)"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:98
msgid "How long to lock out after too many failed attempts."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:118
msgid "IP addresses or CIDR ranges that bypass 2FA. Example: 192.168.1.0/24"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:144
msgid "IP whitelist on"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:57
msgid "Invalid Base32 format. Use only A-Z and 2-7 characters."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:79
msgid "Limit failed OTP attempts to prevent brute-force attacks."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:97
msgid "Lockout Duration (seconds)"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:18
msgid "Login"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:83
msgid "Max Failed Attempts"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:84
msgid "Maximum failed attempts before lockout."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:123
msgid "Minimum Valid Time"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:61
msgid "OTP Type for root"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:90
msgid "Rate Limit Window (seconds)"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:47
msgid "Secret Key for root"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:76
msgid "Security"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:104
msgid "Strict Mode"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:64
msgid "TOTP (Time-based)"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:62
msgid ""
"TOTP (Time-based) is recommended. HOTP (Counter-based) is for special cases."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:68
msgid "TOTP Time Step"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:69
msgid "Time step in seconds for TOTP. Default is 30 seconds."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:91
msgid "Time window for counting failed attempts."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:22
msgid "Two-Factor Authentication"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:124
msgid ""
"Unix timestamp before which system time is considered uncalibrated. Default: "
"2026-01-01."
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:37
msgid "User Configuration"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:117
msgid "Whitelisted IPs"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:141
msgid "rate limiting on"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:138
msgid "root user configured"
msgstr ""

#: applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js:147
msgid "strict mode"
msgstr ""
44 changes: 44 additions & 0 deletions plugins/luci-app-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
133 changes: 133 additions & 0 deletions plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/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';

function hex_to_bin(hex) {
let bin = "";
for (let i = 0; i < length(hex); i += 2) {
bin += chr(int(substr(hex, i, 2), 16));
}
return bin;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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 = hex_to_bin(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 = hex_to_bin(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(">I", 0) + pack(">I", counter_int);
Copy link
Copy Markdown
Contributor

@systemcrash systemcrash Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether it's simpler to do:

pack(">Q", counter_int)

It should work the same I think. ( The RFC 4226 and 6238 use a single 8 byte integer )


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;
Comment on lines +70 to +71
Copy link
Copy Markdown
Contributor

@systemcrash systemcrash Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we get the same result if we do like the RFC?:

offset = hmac[19] & 0xf;
binary_code =
  ((hmac[offset] & 0x7f) << 24) |
  (hmac[offset+1] << 16) |
  (hmac[offset+2] << 8) |
  (hmac[offset+3]);

Need to hexdec sha1 output.


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