From f91d3eb8a00a275aa2fad4104d3e255bc6a3d176 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Thu, 29 Jan 2026 16:37:38 +0800 Subject: [PATCH 1/5] luci-app-2fa: init checkin Co-authored-by: Christian Marangi Signed-off-by: tokisaki galaxy --- applications/luci-app-2fa/Makefile | 23 + .../htdocs/luci-static/resources/uqr.js | 33 + .../luci-app-2fa/po/templates/2fa.pot | 176 +++++ .../root/etc/uci-defaults/luci-app-2fa | 44 ++ .../root/usr/libexec/generate_otp.uc | 133 ++++ .../login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 633 ++++++++++++++++++ .../bb4ea47fcffb44ec9bb3d3673c9b4ed2.js | 145 ++++ 7 files changed, 1187 insertions(+) create mode 100644 applications/luci-app-2fa/Makefile create mode 100644 applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js create mode 100644 applications/luci-app-2fa/po/templates/2fa.pot create mode 100644 applications/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa create mode 100755 applications/luci-app-2fa/root/usr/libexec/generate_otp.uc create mode 100644 applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc create mode 100644 applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js diff --git a/applications/luci-app-2fa/Makefile b/applications/luci-app-2fa/Makefile new file mode 100644 index 000000000000..0b0ac3e3ecc7 --- /dev/null +++ b/applications/luci-app-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-app-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 +ucode-mod-struct +ucode-mod-digest +LUCI_PKGARCH:=all +LUCI_URL:=https://github.com/tokisaki-galaxy/luci-app-2fa + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot diff --git a/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js b/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js new file mode 100644 index 000000000000..27cb0a28d005 --- /dev/null +++ b/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js @@ -0,0 +1,33 @@ +// MIT License + +// Copyright (c) Project Nayuki +// Copyright (c) 2023 Anthony Fu +// Minified and modified/stripped of not useful components +'use strict'; +var QrCodeDataType=(t=>(t[t.Border=-1]="Border",t[t.Data=0]="Data",t[t.Function=1]="Function",t[t.Position=2]="Position",t[t.Timing=3]="Timing",t[t.Alignment=4]="Alignment",t))(QrCodeDataType||{}),__defProp=Object.defineProperty,__defNormalProp=(t,e,n)=>e in t?__defProp(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n,__publicField=(t,e,n)=>(__defNormalProp(t,"symbol"!=typeof e?e+"":e,n),n);const LOW=[0,1],MEDIUM=[1,0],QUARTILE=[2,3],HIGH=[3,2],EccMap={L:LOW,M:MEDIUM,Q:QUARTILE,H:HIGH},NUMERIC_REGEX=/^[0-9]*$/,ALPHANUMERIC_REGEX=/^[A-Z0-9 $%*+./:-]*$/,ALPHANUMERIC_CHARSET="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:",MIN_VERSION=1,MAX_VERSION=40,PENALTY_N1=3,PENALTY_N2=3,PENALTY_N3=40,PENALTY_N4=10,ECC_CODEWORDS_PER_BLOCK=[[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]],NUM_ERROR_CORRECTION_BLOCKS=[[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]];class QrCode{constructor(t,e,n,r){if(this.version=t,this.ecc=e,__publicField(this,"size"),__publicField(this,"mask"),__publicField(this,"modules",[]),__publicField(this,"types",[]),tMAX_VERSION)throw new RangeError("Version value out of range");if(r<-1||r>7)throw new RangeError("Mask value out of range");this.size=4*t+17;const o=Array.from({length:this.size},(()=>!1));for(let t=0;t0)));this.drawFunctionPatterns();const s=this.addEccAndInterleave(n);if(this.drawCodewords(s),-1===r){let t=1e9;for(let e=0;e<8;e++){this.applyMask(e),this.drawFormatBits(e);const n=this.getPenaltyScore();n=0&&t=0&&e>>9);const r=21522^(e<<10|n);for(let t=0;t<=5;t++)this.setFunctionModule(8,t,getBit(r,t));this.setFunctionModule(8,7,getBit(r,6)),this.setFunctionModule(8,8,getBit(r,7)),this.setFunctionModule(7,8,getBit(r,8));for(let t=9;t<15;t++)this.setFunctionModule(14-t,8,getBit(r,t));for(let t=0;t<8;t++)this.setFunctionModule(this.size-1-t,8,getBit(r,t));for(let t=8;t<15;t++)this.setFunctionModule(8,this.size-15+t,getBit(r,t));this.setFunctionModule(8,this.size-8,!0)}drawVersion(){if(this.version<7)return;let t=this.version;for(let e=0;e<12;e++)t=t<<1^7973*(t>>>11);const e=this.version<<12|t;for(let t=0;t<18;t++){const n=getBit(e,t),r=this.size-11+t%3,o=Math.floor(t/3);this.setFunctionModule(r,o,n),this.setFunctionModule(o,r,n)}}drawFinderPattern(t,e){for(let n=-4;n<=4;n++)for(let r=-4;r<=4;r++){const o=Math.max(Math.abs(r),Math.abs(n)),s=t+r,i=e+n;s>=0&&s=0&&i{(t!==a-o||n>=i)&&u.push(e[t])}));return u}drawCodewords(t){if(t.length!==Math.floor(getNumRawDataModules(this.version)/8))throw new RangeError("Invalid argument");let e=0;for(let n=this.size-1;n>=1;n-=2){6===n&&(n=5);for(let r=0;r>>3],7-(7&e)),e++)}}}applyMask(t){if(t<0||t>7)throw new RangeError("Mask value out of range");for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(r,o),n||(t+=40*this.finderPenaltyCountPatterns(o)),n=this.modules[e][s],r=1);t+=40*this.finderPenaltyTerminateAndCount(n,r,o)}for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(r,o),n||(t+=40*this.finderPenaltyCountPatterns(o)),n=this.modules[s][e],r=1);t+=40*this.finderPenaltyTerminateAndCount(n,r,o)}for(let e=0;et+(e?1:0)),e);const n=this.size*this.size;return t+=10*(Math.ceil(Math.abs(20*e-10*n)/n)-1),t}getAlignmentPatternPositions(){if(1===this.version)return[];{const t=Math.floor(this.version/7)+2,e=32===this.version?26:2*Math.ceil((4*this.version+4)/(2*t-2)),n=[6];for(let r=this.size-7;n.length0&&t[2]===e&&t[3]===3*e&&t[4]===e&&t[5]===e;return(n&&t[0]>=4*e&&t[6]>=e?1:0)+(n&&t[6]>=4*e&&t[0]>=e?1:0)}finderPenaltyTerminateAndCount(t,e,n){return t&&(this.finderPenaltyAddHistory(e,n),e=0),e+=this.size,this.finderPenaltyAddHistory(e,n),this.finderPenaltyCountPatterns(n)}finderPenaltyAddHistory(t,e){0===e[0]&&(t+=this.size),e.pop(),e.unshift(t)}}function appendBits(t,e,n){if(e<0||e>31||t>>>e!=0)throw new RangeError("Value out of range");for(let r=e-1;r>=0;r--)n.push(t>>>r&1)}function getBit(t,e){return 0!=(t>>>e&1)}class QrSegment{constructor(t,e,n){if(this.mode=t,this.numChars=e,this.bitData=n,e<0)throw new RangeError("Invalid argument");this.bitData=n.slice()}getData(){return this.bitData.slice()}}const MODE_NUMERIC=[1,10,12,14],MODE_ALPHANUMERIC=[2,9,11,13],MODE_BYTE=[4,8,16,16];function numCharCountBits(t,e){return t[Math.floor((e+7)/17)+1]}function makeBytes(t){const e=[];for(const n of t)appendBits(n,8,e);return new QrSegment(MODE_BYTE,t.length,e)}function makeNumeric(t){if(!isNumeric(t))throw new RangeError("String contains non-numeric characters");const e=[];for(let n=0;n=1<MAX_VERSION)throw new RangeError("Version number out of range");let e=(16*t+128)*t+64;if(t>=2){const n=Math.floor(t/7)+2;e-=(25*n-10)*n-55,t>=7&&(e-=36)}return e}function getNumDataCodewords(t,e){return Math.floor(getNumRawDataModules(t)/8)-ECC_CODEWORDS_PER_BLOCK[e[0]][t]*NUM_ERROR_CORRECTION_BLOCKS[e[0]][t]}function reedSolomonComputeDivisor(t){if(t<1||t>255)throw new RangeError("Degree out of range");const e=[];for(let n=0;n0));for(const r of t){const t=r^n.shift();n.push(0),e.forEach(((e,r)=>n[r]^=reedSolomonMultiply(e,t)))}return n}function reedSolomonMultiply(t,e){if(t>>>8!=0||e>>>8!=0)throw new RangeError("Byte out of range");let n=0;for(let r=7;r>=0;r--)n=n<<1^285*(n>>>7),n^=(e>>>r&1)*t;return n}function encodeSegments(t,e,n=1,r=40,o=-1,s=!0){if(!(MIN_VERSION<=n&&n<=r&&r<=MAX_VERSION)||o<-1||o>7)throw new RangeError("Invalid value");let i,a;for(i=n;;i++){const n=8*getNumDataCodewords(i,e),o=getTotalBits(t,i);if(o<=n){a=o;break}if(i>=r)throw new RangeError("Data too long")}for(const t of[MEDIUM,QUARTILE,HIGH])s&&a<=8*getNumDataCodewords(i,t)&&(e=t);const h=[];for(const e of t){appendBits(e.mode[0],4,h),appendBits(e.numChars,numCharCountBits(e.mode,i),h);for(const t of e.getData())h.push(t)}const l=8*getNumDataCodewords(i,e);appendBits(0,Math.min(4,l-h.length),h),appendBits(0,(8-h.length%8)%8,h);for(let t=236;h.length0));return h.forEach(((t,e)=>u[e>>>3]|=t<<7-(7&e))),new QrCode(i,e,u,o)}function encode(t,e){const{ecc:n="L",boostEcc:r=!1,minVersion:o=1,maxVersion:s=40,maskPattern:i=-1,border:a=1}=e||{},h="string"==typeof t?makeSegments(t):Array.isArray(t)?[makeBytes(t)]:void 0;if(!h)throw new Error("uqr only supports encoding string and binary data, but got: "+typeof t);const l=encodeSegments(h,EccMap[n],o,s,i,r),u=addBorder({version:l.version,maskPattern:l.mask,size:l.size,data:l.modules,types:l.types},a);return e?.invert&&(u.data=u.data.map((t=>t.map((t=>!t))))),e?.onEncoded?.(u),u}function addBorder(t,e=1){if(!e)return t;const{size:n}=t,r=n+2*e;t.size=r,t.data.forEach((t=>{for(let n=0;n!1))),t.data.push(Array.from({length:r},(t=>!1)));const o=QrCodeDataType.Border;t.types.forEach((t=>{for(let n=0;no))),t.types.push(Array.from({length:r},(t=>o)));return t} +return L.Class.extend({ + renderSVG: function(data, options = {}) { + const result = encode(data, options); + const { + pixelSize = 1, + whiteColor = "white", + blackColor = "black" + } = options; + const height = result.size * pixelSize; + const width = result.size * pixelSize; + let svg = ``; + const pathes = []; + for (let row = 0; row < result.size; row++) { + for (let col = 0; col < result.size; col++) { + const x = col * pixelSize; + const y = row * pixelSize; + if (result.data[row][col]) + pathes.push(`M${x},${y}h${pixelSize}v${pixelSize}h-${pixelSize}z`); + } + } + svg += ``; + svg += ``; + svg += ""; + return svg; + }, +}); diff --git a/applications/luci-app-2fa/po/templates/2fa.pot b/applications/luci-app-2fa/po/templates/2fa.pot new file mode 100644 index 000000000000..ddbf4bca2260 --- /dev/null +++ b/applications/luci-app-2fa/po/templates/2fa.pot @@ -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 "" diff --git a/applications/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa b/applications/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa new file mode 100644 index 000000000000..d2bc594040d6 --- /dev/null +++ b/applications/luci-app-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/applications/luci-app-2fa/root/usr/libexec/generate_otp.uc b/applications/luci-app-2fa/root/usr/libexec/generate_otp.uc new file mode 100755 index 000000000000..22b9358c641b --- /dev/null +++ b/applications/luci-app-2fa/root/usr/libexec/generate_otp.uc @@ -0,0 +1,133 @@ +#!/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'; + +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; +} + +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); + + 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/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc new file mode 100644 index 000000000000..7d459259e23b --- /dev/null +++ b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -0,0 +1,633 @@ +// 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 } from 'fs'; +import { cursor } from 'uci'; + +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'; + +// 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) : DEFAULT_MIN_VALID_TIME; + 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; + // IPv4 pattern - validate each octet is 0-255 + if (match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) { + let parts = split(ip, '.'); + for (let i = 0; i < length(parts); i++) { + if (int(parts[i]) > 255) return false; + } + return true; + } + // IPv4 CIDR pattern + if (match(ip, /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/)) { + let cidr_parts = split(ip, '/'); + let prefix = int(cidr_parts[1]); + if (prefix < 0 || prefix > 32) return false; + let ip_parts = split(cidr_parts[0], '.'); + for (let i = 0; i < length(ip_parts); i++) { + if (int(ip_parts[i]) > 255) return false; + } + return true; + } + // IPv6 pattern (simplified) + if (match(ip, /^[0-9a-fA-F:]+$/) && index(ip, ':') >= 0) + return true; + // IPv6 CIDR pattern + if (match(ip, /^[0-9a-fA-F:]+\/\d{1,3}$/) && index(ip, ':') >= 0) { + let cidr_parts = split(ip, '/'); + let prefix = int(cidr_parts[1]); + if (prefix < 0 || prefix > 128) return false; + return true; + } + return false; +} + +// Check if an IP is in a CIDR range +function ip_in_cidr(ip, cidr) { + let parts = split(cidr, '/'); + let network_ip = parts[0]; + let prefix = (length(parts) > 1) ? int(parts[1]) : 32; + + // For IPv6, fall back to exact string comparison + if (!match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) + return ip == network_ip; + + if (!match(network_ip, /^(\d{1,3}\.){3}\d{1,3}$/)) + return false; + + let ip_parts = split(ip, '.'); + let net_parts = split(network_ip, '.'); + + let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); + let net_int = (int(net_parts[0]) << 24) | (int(net_parts[1]) << 16) | (int(net_parts[2]) << 8) | int(net_parts[3]); + + let mask = 0; + if (prefix > 0) { + mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF; + } + + return ((ip_int & mask) == (net_int & mask)); +} + +// 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 = []; + + // Try ubus call to get LAN interface status + let fd = popen('ubus call network.interface.lan status 2>/dev/null', 'r'); + if (fd) { + let output = fd.read('all'); + fd.close(); + + if (output) { + let status = json(output); + if (status && status['ipv4-address']) { + for (let addr in status['ipv4-address']) { + if (addr.address && addr.mask) { + let ip_parts = split(addr.address, '.'); + if (length(ip_parts) == 4) { + let mask = int(addr.mask); + let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); + let net_mask = (0xFFFFFFFF << (32 - mask)) & 0xFFFFFFFF; + let net_int = ip_int & net_mask; + let net_addr = sprintf('%d.%d.%d.%d', + (net_int >> 24) & 0xFF, + (net_int >> 16) & 0xFF, + (net_int >> 8) & 0xFF, + net_int & 0xFF); + push(subnets, net_addr + '/' + mask); + } + } + } + } + } + } + + // 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 mask_parts = split(lan_netmask, '.'); + if (length(mask_parts) == 4) { + let mask_int = (int(mask_parts[0]) << 24) | (int(mask_parts[1]) << 16) | (int(mask_parts[2]) << 8) | int(mask_parts[3]); + let prefix = 0; + for (let i = 31; i >= 0; i--) { + if ((mask_int >> i) & 1) prefix++; + else break; + } + + let ip_parts = split(lan_ipaddr, '.'); + if (length(ip_parts) == 4) { + let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); + let net_int = ip_int & mask_int; + let net_addr = sprintf('%d.%d.%d.%d', + (net_int >> 24) & 0xFF, + (net_int >> 16) & 0xFF, + (net_int >> 8) & 0xFF, + net_int & 0xFF); + push(subnets, net_addr + '/' + prefix); + } + } + } + } + + return subnets; +} + +// Check if IP is in a LAN subnet +function is_local_subnet(ip) { + if (!ip || ip == '') + return false; + + if (!match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) + 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; +} + +// Save rate limit state +function save_rate_limit_state(state) { + writefile(RATE_LIMIT_FILE, sprintf('%J', state)); +} + +function lock_rate_limit_state() { + let fd = popen('lock -w 5 ' + RATE_LIMIT_LOCK_FILE + ' >/dev/null 2>&1; echo $?', 'r'); + if (!fd) + return false; + + let status = trim(fd.read('all') || ''); + fd.close(); + + return status == '0'; +} + +function unlock_rate_limit_state() { + let fd = popen('lock -u ' + RATE_LIMIT_LOCK_FILE + ' >/dev/null 2>&1', 'r'); + if (fd) + fd.close(); +} + +// Check rate limit +function check_rate_limit(ip) { + 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 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; + + let remaining = max_attempts - length(ip_state.attempts); + if (remaining <= 0) { + 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; + } + + save_rate_limit_state(state); + result = { allowed: true, remaining: remaining, locked_until: 0 }; + unlock_rate_limit_state(); + return result; +} + +// Reserve a rate-limit attempt atomically before verification +function consume_rate_limit_attempt(ip) { + 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 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; + } + + 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; +} + +// 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: 10, + + 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)) { + 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(); + 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 == '') { + return { + success: false, + message: 'Please enter your one-time password.' + }; + } + + let verify_result = verify_otp(user, otp); + + if (!verify_result.success) { + 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); + + return { success: true }; + } +}; diff --git a/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js b/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js new file mode 100644 index 000000000000..fdfbfacfd181 --- /dev/null +++ b/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js @@ -0,0 +1,145 @@ +'use strict'; +'require baseclass'; +'require form'; +'require uci'; +'require rpc'; + +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; + + // 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; + + // 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'); + } +}); From 6dd1bea06967a5f44b9317578990a816b794d761 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Fri, 3 Apr 2026 12:07:37 +0000 Subject: [PATCH 2/5] luci-app-2fa: add priority option and QR code display This update adds a priority option and enables QR code display for 2FA. Signed-off-by: tokisaki galaxy --- applications/luci-app-2fa/Makefile | 2 +- .../htdocs/luci-static/resources/uqr.js | 33 ------------- .../login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 13 +++++- .../bb4ea47fcffb44ec9bb3d3673c9b4ed2.js | 46 +++++++++++++++++++ 4 files changed, 59 insertions(+), 35 deletions(-) delete mode 100644 applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js diff --git a/applications/luci-app-2fa/Makefile b/applications/luci-app-2fa/Makefile index 0b0ac3e3ecc7..0da479102ba7 100644 --- a/applications/luci-app-2fa/Makefile +++ b/applications/luci-app-2fa/Makefile @@ -14,7 +14,7 @@ PKG_MAINTAINER:=tokisaki galaxy PKG_DESCRIPTION:=LuCI 2-Factor Authentication Plugin LUCI_TITLE:=LuCI 2-Factor Authentication -LUCI_DEPENDS:=+luci-base +ucode-mod-struct +ucode-mod-digest +LUCI_DEPENDS:=+luci-base +luci-lib-uqr +ucode-mod-struct +ucode-mod-digest LUCI_PKGARCH:=all LUCI_URL:=https://github.com/tokisaki-galaxy/luci-app-2fa diff --git a/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js b/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js deleted file mode 100644 index 27cb0a28d005..000000000000 --- a/applications/luci-app-2fa/htdocs/luci-static/resources/uqr.js +++ /dev/null @@ -1,33 +0,0 @@ -// MIT License - -// Copyright (c) Project Nayuki -// Copyright (c) 2023 Anthony Fu -// Minified and modified/stripped of not useful components -'use strict'; -var QrCodeDataType=(t=>(t[t.Border=-1]="Border",t[t.Data=0]="Data",t[t.Function=1]="Function",t[t.Position=2]="Position",t[t.Timing=3]="Timing",t[t.Alignment=4]="Alignment",t))(QrCodeDataType||{}),__defProp=Object.defineProperty,__defNormalProp=(t,e,n)=>e in t?__defProp(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n,__publicField=(t,e,n)=>(__defNormalProp(t,"symbol"!=typeof e?e+"":e,n),n);const LOW=[0,1],MEDIUM=[1,0],QUARTILE=[2,3],HIGH=[3,2],EccMap={L:LOW,M:MEDIUM,Q:QUARTILE,H:HIGH},NUMERIC_REGEX=/^[0-9]*$/,ALPHANUMERIC_REGEX=/^[A-Z0-9 $%*+./:-]*$/,ALPHANUMERIC_CHARSET="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:",MIN_VERSION=1,MAX_VERSION=40,PENALTY_N1=3,PENALTY_N2=3,PENALTY_N3=40,PENALTY_N4=10,ECC_CODEWORDS_PER_BLOCK=[[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]],NUM_ERROR_CORRECTION_BLOCKS=[[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]];class QrCode{constructor(t,e,n,r){if(this.version=t,this.ecc=e,__publicField(this,"size"),__publicField(this,"mask"),__publicField(this,"modules",[]),__publicField(this,"types",[]),tMAX_VERSION)throw new RangeError("Version value out of range");if(r<-1||r>7)throw new RangeError("Mask value out of range");this.size=4*t+17;const o=Array.from({length:this.size},(()=>!1));for(let t=0;t0)));this.drawFunctionPatterns();const s=this.addEccAndInterleave(n);if(this.drawCodewords(s),-1===r){let t=1e9;for(let e=0;e<8;e++){this.applyMask(e),this.drawFormatBits(e);const n=this.getPenaltyScore();n=0&&t=0&&e>>9);const r=21522^(e<<10|n);for(let t=0;t<=5;t++)this.setFunctionModule(8,t,getBit(r,t));this.setFunctionModule(8,7,getBit(r,6)),this.setFunctionModule(8,8,getBit(r,7)),this.setFunctionModule(7,8,getBit(r,8));for(let t=9;t<15;t++)this.setFunctionModule(14-t,8,getBit(r,t));for(let t=0;t<8;t++)this.setFunctionModule(this.size-1-t,8,getBit(r,t));for(let t=8;t<15;t++)this.setFunctionModule(8,this.size-15+t,getBit(r,t));this.setFunctionModule(8,this.size-8,!0)}drawVersion(){if(this.version<7)return;let t=this.version;for(let e=0;e<12;e++)t=t<<1^7973*(t>>>11);const e=this.version<<12|t;for(let t=0;t<18;t++){const n=getBit(e,t),r=this.size-11+t%3,o=Math.floor(t/3);this.setFunctionModule(r,o,n),this.setFunctionModule(o,r,n)}}drawFinderPattern(t,e){for(let n=-4;n<=4;n++)for(let r=-4;r<=4;r++){const o=Math.max(Math.abs(r),Math.abs(n)),s=t+r,i=e+n;s>=0&&s=0&&i{(t!==a-o||n>=i)&&u.push(e[t])}));return u}drawCodewords(t){if(t.length!==Math.floor(getNumRawDataModules(this.version)/8))throw new RangeError("Invalid argument");let e=0;for(let n=this.size-1;n>=1;n-=2){6===n&&(n=5);for(let r=0;r>>3],7-(7&e)),e++)}}}applyMask(t){if(t<0||t>7)throw new RangeError("Mask value out of range");for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(r,o),n||(t+=40*this.finderPenaltyCountPatterns(o)),n=this.modules[e][s],r=1);t+=40*this.finderPenaltyTerminateAndCount(n,r,o)}for(let e=0;e5&&t++):(this.finderPenaltyAddHistory(r,o),n||(t+=40*this.finderPenaltyCountPatterns(o)),n=this.modules[s][e],r=1);t+=40*this.finderPenaltyTerminateAndCount(n,r,o)}for(let e=0;et+(e?1:0)),e);const n=this.size*this.size;return t+=10*(Math.ceil(Math.abs(20*e-10*n)/n)-1),t}getAlignmentPatternPositions(){if(1===this.version)return[];{const t=Math.floor(this.version/7)+2,e=32===this.version?26:2*Math.ceil((4*this.version+4)/(2*t-2)),n=[6];for(let r=this.size-7;n.length0&&t[2]===e&&t[3]===3*e&&t[4]===e&&t[5]===e;return(n&&t[0]>=4*e&&t[6]>=e?1:0)+(n&&t[6]>=4*e&&t[0]>=e?1:0)}finderPenaltyTerminateAndCount(t,e,n){return t&&(this.finderPenaltyAddHistory(e,n),e=0),e+=this.size,this.finderPenaltyAddHistory(e,n),this.finderPenaltyCountPatterns(n)}finderPenaltyAddHistory(t,e){0===e[0]&&(t+=this.size),e.pop(),e.unshift(t)}}function appendBits(t,e,n){if(e<0||e>31||t>>>e!=0)throw new RangeError("Value out of range");for(let r=e-1;r>=0;r--)n.push(t>>>r&1)}function getBit(t,e){return 0!=(t>>>e&1)}class QrSegment{constructor(t,e,n){if(this.mode=t,this.numChars=e,this.bitData=n,e<0)throw new RangeError("Invalid argument");this.bitData=n.slice()}getData(){return this.bitData.slice()}}const MODE_NUMERIC=[1,10,12,14],MODE_ALPHANUMERIC=[2,9,11,13],MODE_BYTE=[4,8,16,16];function numCharCountBits(t,e){return t[Math.floor((e+7)/17)+1]}function makeBytes(t){const e=[];for(const n of t)appendBits(n,8,e);return new QrSegment(MODE_BYTE,t.length,e)}function makeNumeric(t){if(!isNumeric(t))throw new RangeError("String contains non-numeric characters");const e=[];for(let n=0;n=1<MAX_VERSION)throw new RangeError("Version number out of range");let e=(16*t+128)*t+64;if(t>=2){const n=Math.floor(t/7)+2;e-=(25*n-10)*n-55,t>=7&&(e-=36)}return e}function getNumDataCodewords(t,e){return Math.floor(getNumRawDataModules(t)/8)-ECC_CODEWORDS_PER_BLOCK[e[0]][t]*NUM_ERROR_CORRECTION_BLOCKS[e[0]][t]}function reedSolomonComputeDivisor(t){if(t<1||t>255)throw new RangeError("Degree out of range");const e=[];for(let n=0;n0));for(const r of t){const t=r^n.shift();n.push(0),e.forEach(((e,r)=>n[r]^=reedSolomonMultiply(e,t)))}return n}function reedSolomonMultiply(t,e){if(t>>>8!=0||e>>>8!=0)throw new RangeError("Byte out of range");let n=0;for(let r=7;r>=0;r--)n=n<<1^285*(n>>>7),n^=(e>>>r&1)*t;return n}function encodeSegments(t,e,n=1,r=40,o=-1,s=!0){if(!(MIN_VERSION<=n&&n<=r&&r<=MAX_VERSION)||o<-1||o>7)throw new RangeError("Invalid value");let i,a;for(i=n;;i++){const n=8*getNumDataCodewords(i,e),o=getTotalBits(t,i);if(o<=n){a=o;break}if(i>=r)throw new RangeError("Data too long")}for(const t of[MEDIUM,QUARTILE,HIGH])s&&a<=8*getNumDataCodewords(i,t)&&(e=t);const h=[];for(const e of t){appendBits(e.mode[0],4,h),appendBits(e.numChars,numCharCountBits(e.mode,i),h);for(const t of e.getData())h.push(t)}const l=8*getNumDataCodewords(i,e);appendBits(0,Math.min(4,l-h.length),h),appendBits(0,(8-h.length%8)%8,h);for(let t=236;h.length0));return h.forEach(((t,e)=>u[e>>>3]|=t<<7-(7&e))),new QrCode(i,e,u,o)}function encode(t,e){const{ecc:n="L",boostEcc:r=!1,minVersion:o=1,maxVersion:s=40,maskPattern:i=-1,border:a=1}=e||{},h="string"==typeof t?makeSegments(t):Array.isArray(t)?[makeBytes(t)]:void 0;if(!h)throw new Error("uqr only supports encoding string and binary data, but got: "+typeof t);const l=encodeSegments(h,EccMap[n],o,s,i,r),u=addBorder({version:l.version,maskPattern:l.mask,size:l.size,data:l.modules,types:l.types},a);return e?.invert&&(u.data=u.data.map((t=>t.map((t=>!t))))),e?.onEncoded?.(u),u}function addBorder(t,e=1){if(!e)return t;const{size:n}=t,r=n+2*e;t.size=r,t.data.forEach((t=>{for(let n=0;n!1))),t.data.push(Array.from({length:r},(t=>!1)));const o=QrCodeDataType.Border;t.types.forEach((t=>{for(let n=0;no))),t.types.push(Array.from({length:r},(t=>o)));return t} -return L.Class.extend({ - renderSVG: function(data, options = {}) { - const result = encode(data, options); - const { - pixelSize = 1, - whiteColor = "white", - blackColor = "black" - } = options; - const height = result.size * pixelSize; - const width = result.size * pixelSize; - let svg = ``; - const pathes = []; - for (let row = 0; row < result.size; row++) { - for (let col = 0; col < result.size; col++) { - const x = col * pixelSize; - const y = row * pixelSize; - if (result.data[row][col]) - pathes.push(`M${x},${y}h${pixelSize}v${pixelSize}h-${pixelSize}z`); - } - } - svg += ``; - svg += ``; - svg += ""; - return svg; - }, -}); diff --git a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc index 7d459259e23b..dc1f8f12f494 100644 --- a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc +++ b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -25,6 +25,17 @@ 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; + +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); +} // Check if system time is calibrated (not earlier than minimum valid time) function check_time_calibration() { @@ -509,7 +520,7 @@ function get_client_ip(http) { } return { - priority: 10, + priority: get_priority(), check: function(http, user) { let client_ip = get_client_ip(http); diff --git a/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js b/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js index fdfbfacfd181..b1547b046e0c 100644 --- a/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js +++ b/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js @@ -3,6 +3,42 @@ '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', @@ -27,6 +63,13 @@ return baseclass.extend({ 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.')); @@ -66,6 +109,9 @@ return baseclass.extend({ 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')); From 2bb89660c4cdf80253f15c636af0a07f20e2d6d3 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Fri, 3 Apr 2026 14:28:43 +0000 Subject: [PATCH 3/5] 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. Signed-off-by: tokisaki galaxy --- applications/luci-app-2fa/Makefile | 2 +- .../login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 463 ++++++++++-------- 2 files changed, 260 insertions(+), 205 deletions(-) diff --git a/applications/luci-app-2fa/Makefile b/applications/luci-app-2fa/Makefile index 0da479102ba7..befd2e1e20e4 100644 --- a/applications/luci-app-2fa/Makefile +++ b/applications/luci-app-2fa/Makefile @@ -14,7 +14,7 @@ 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 +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 diff --git a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc index dc1f8f12f494..746a8ea18a53 100644 --- a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc +++ b/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -10,8 +10,10 @@ 'use strict'; -import { popen, readfile, writefile } from 'fs'; +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'; @@ -26,6 +28,9 @@ const DEFAULT_MIN_VALID_TIME = 1767225600; 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(); @@ -43,7 +48,7 @@ function check_time_calibration() { let config_time = ctx.get('luci_plugins', PLUGIN_UUID, 'min_valid_time'); let min_valid_time = config_time ? int(config_time) : DEFAULT_MIN_VALID_TIME; let current_time = time(); - + return { calibrated: current_time >= min_valid_time, current_time: current_time, @@ -74,83 +79,136 @@ function sanitize_username(username) { function is_valid_ip(ip) { if (!ip || ip == '') return false; - // IPv4 pattern - validate each octet is 0-255 - if (match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) { - let parts = split(ip, '.'); - for (let i = 0; i < length(parts); i++) { - if (int(parts[i]) > 255) 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 true; } - // IPv4 CIDR pattern - if (match(ip, /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/)) { - let cidr_parts = split(ip, '/'); - let prefix = int(cidr_parts[1]); - if (prefix < 0 || prefix > 32) return false; - let ip_parts = split(cidr_parts[0], '.'); - for (let i = 0; i < length(ip_parts); i++) { - if (int(ip_parts[i]) > 255) return false; + + 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 true; } - // IPv6 pattern (simplified) - if (match(ip, /^[0-9a-fA-F:]+$/) && index(ip, ':') >= 0) - return true; - // IPv6 CIDR pattern - if (match(ip, /^[0-9a-fA-F:]+\/\d{1,3}$/) && index(ip, ':') >= 0) { - let cidr_parts = split(ip, '/'); - let prefix = int(cidr_parts[1]); - if (prefix < 0 || prefix > 128) return false; - return 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); } - return false; } // Check if an IP is in a CIDR range function ip_in_cidr(ip, cidr) { - let parts = split(cidr, '/'); - let network_ip = parts[0]; - let prefix = (length(parts) > 1) ? int(parts[1]) : 32; - - // For IPv6, fall back to exact string comparison - if (!match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) - return ip == network_ip; - - if (!match(network_ip, /^(\d{1,3}\.){3}\d{1,3}$/)) + let addr = iptoarr(ip); + let network = parse_cidr(cidr); + if (!addr || !network) return false; - - let ip_parts = split(ip, '.'); - let net_parts = split(network_ip, '.'); - - let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); - let net_int = (int(net_parts[0]) << 24) | (int(net_parts[1]) << 16) | (int(net_parts[2]) << 8) | int(net_parts[3]); - - let mask = 0; - if (prefix > 0) { - mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF; - } - - return ((ip_int & mask) == (net_int & mask)); + + 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; @@ -162,75 +220,41 @@ function is_ip_whitelisted(ip) { return true; } } - + return false; } // Get all LAN interface subnets from OpenWrt network configuration function get_lan_subnets() { let subnets = []; - - // Try ubus call to get LAN interface status - let fd = popen('ubus call network.interface.lan status 2>/dev/null', 'r'); - if (fd) { - let output = fd.read('all'); - fd.close(); - - if (output) { - let status = json(output); - if (status && status['ipv4-address']) { - for (let addr in status['ipv4-address']) { - if (addr.address && addr.mask) { - let ip_parts = split(addr.address, '.'); - if (length(ip_parts) == 4) { - let mask = int(addr.mask); - let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); - let net_mask = (0xFFFFFFFF << (32 - mask)) & 0xFFFFFFFF; - let net_int = ip_int & net_mask; - let net_addr = sprintf('%d.%d.%d.%d', - (net_int >> 24) & 0xFF, - (net_int >> 16) & 0xFF, - (net_int >> 8) & 0xFF, - net_int & 0xFF); - push(subnets, net_addr + '/' + mask); - } - } - } - } - } - } - + 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 mask_parts = split(lan_netmask, '.'); - if (length(mask_parts) == 4) { - let mask_int = (int(mask_parts[0]) << 24) | (int(mask_parts[1]) << 16) | (int(mask_parts[2]) << 8) | int(mask_parts[3]); - let prefix = 0; - for (let i = 31; i >= 0; i--) { - if ((mask_int >> i) & 1) prefix++; - else break; - } - - let ip_parts = split(lan_ipaddr, '.'); - if (length(ip_parts) == 4) { - let ip_int = (int(ip_parts[0]) << 24) | (int(ip_parts[1]) << 16) | (int(ip_parts[2]) << 8) | int(ip_parts[3]); - let net_int = ip_int & mask_int; - let net_addr = sprintf('%d.%d.%d.%d', - (net_int >> 24) & 0xFF, - (net_int >> 16) & 0xFF, - (net_int >> 8) & 0xFF, - net_int & 0xFF); - push(subnets, net_addr + '/' + prefix); - } + 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; } @@ -238,17 +262,18 @@ function get_lan_subnets() { function is_local_subnet(ip) { if (!ip || ip == '') return false; - - if (!match(ip, /^(\d{1,3}\.){3}\d{1,3}$/)) + + 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; } @@ -257,95 +282,93 @@ 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() { - let fd = popen('lock -w 5 ' + RATE_LIMIT_LOCK_FILE + ' >/dev/null 2>&1; echo $?', 'r'); + if (RATE_LIMIT_LOCK_HANDLE) + return true; + + let fd = open(RATE_LIMIT_LOCK_FILE, 'w', 0600); if (!fd) return false; - let status = trim(fd.read('all') || ''); - fd.close(); + if (fd.lock('xn') !== true) { + fd.close(); + return false; + } - return status == '0'; + RATE_LIMIT_LOCK_HANDLE = fd; + return true; } function unlock_rate_limit_state() { - let fd = popen('lock -u ' + RATE_LIMIT_LOCK_FILE + ' >/dev/null 2>&1', 'r'); - if (fd) - fd.close(); -} - -// Check rate limit -function check_rate_limit(ip) { - 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 (!RATE_LIMIT_LOCK_HANDLE) + return; - 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 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; - - let remaining = max_attempts - length(ip_state.attempts); - if (remaining <= 0) { - 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; - } - - save_rate_limit_state(state); - result = { allowed: true, remaining: remaining, locked_until: 0 }; - unlock_rate_limit_state(); - return result; + RATE_LIMIT_LOCK_HANDLE.lock('u'); + RATE_LIMIT_LOCK_HANDLE.close(); + RATE_LIMIT_LOCK_HANDLE = null; } -// Reserve a rate-limit attempt atomically before verification -function consume_rate_limit_attempt(ip) { +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 }; @@ -356,11 +379,15 @@ function consume_rate_limit_attempt(ip) { 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 }; } @@ -389,13 +416,25 @@ function consume_rate_limit_attempt(ip) { return result; } - push(ip_state.attempts, now); + 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()) @@ -414,7 +453,7 @@ function clear_rate_limit(ip) { // 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') @@ -435,7 +474,7 @@ function is_2fa_enabled(username) { // Verify OTP for user function verify_otp(username, otp) { let ctx = cursor(); - + if (!otp || otp == '') return { success: false }; @@ -444,7 +483,7 @@ function verify_otp(username, otp) { return { success: false }; otp = trim(otp); - + if (!match(otp, /^[0-9]{6}$/)) return { success: false }; @@ -460,7 +499,7 @@ function verify_otp(username, otp) { let expected_otp = fd.read('all'); fd.close(); expected_otp = trim(expected_otp); - + if (!match(expected_otp, /^[0-9]{6}$/)) return { success: false }; @@ -477,7 +516,7 @@ function verify_otp(username, otp) { 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)); @@ -488,7 +527,7 @@ function verify_otp(username, otp) { let expected_otp = fd.read('all'); fd.close(); expected_otp = trim(expected_otp); - + if (!match(expected_otp, /^[0-9]{6}$/)) continue; @@ -503,10 +542,10 @@ function verify_otp(username, otp) { // 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) { @@ -515,7 +554,7 @@ function get_client_ip(http) { } } } - + return ip || ''; } @@ -524,12 +563,12 @@ return { 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); @@ -543,7 +582,7 @@ return { }; } } - + if (!is_2fa_enabled(user)) { return { required: false }; } @@ -552,12 +591,12 @@ return { 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 }; @@ -596,17 +635,23 @@ return { verify: function(http, user) { let client_ip = get_client_ip(http); - + // Check if IP is whitelisted - if (client_ip && is_ip_whitelisted(client_ip)) { - return { success: true, whitelisted: true }; - } - + 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, @@ -614,13 +659,16 @@ return { }; } } - + 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.' @@ -628,8 +676,11 @@ return { } 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.' @@ -638,6 +689,10 @@ return { // 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 }; } From 2faafc4e0ddfdc63465a18cac18bd78c168565fa Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Sat, 4 Apr 2026 02:54:45 +0000 Subject: [PATCH 4/5] luci-app-2fa: move dir and sync sysfixtime move to the new location. update the default time calibration threshold to sync sysfixtime. Signed-off-by: tokisaki galaxy --- .../luci-app-2fa/Makefile | 0 .../luci-app-2fa/po/templates/2fa.pot | 0 .../root/etc/uci-defaults/luci-app-2fa | 0 .../root/usr/libexec/generate_otp.uc | 0 .../login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 23 ++++++++++++++++++- .../bb4ea47fcffb44ec9bb3d3673c9b4ed2.js | 0 6 files changed, 22 insertions(+), 1 deletion(-) rename {applications => plugins}/luci-app-2fa/Makefile (100%) rename {applications => plugins}/luci-app-2fa/po/templates/2fa.pot (100%) rename {applications => plugins}/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa (100%) rename {applications => plugins}/luci-app-2fa/root/usr/libexec/generate_otp.uc (100%) rename {applications => plugins}/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc (96%) rename {applications => plugins}/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js (100%) diff --git a/applications/luci-app-2fa/Makefile b/plugins/luci-app-2fa/Makefile similarity index 100% rename from applications/luci-app-2fa/Makefile rename to plugins/luci-app-2fa/Makefile diff --git a/applications/luci-app-2fa/po/templates/2fa.pot b/plugins/luci-app-2fa/po/templates/2fa.pot similarity index 100% rename from applications/luci-app-2fa/po/templates/2fa.pot rename to plugins/luci-app-2fa/po/templates/2fa.pot diff --git a/applications/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa b/plugins/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa similarity index 100% rename from applications/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa rename to plugins/luci-app-2fa/root/etc/uci-defaults/luci-app-2fa diff --git a/applications/luci-app-2fa/root/usr/libexec/generate_otp.uc b/plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc similarity index 100% rename from applications/luci-app-2fa/root/usr/libexec/generate_otp.uc rename to plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc diff --git a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc similarity index 96% rename from applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc rename to plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc index 746a8ea18a53..7a7a6d3214bd 100644 --- a/applications/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc +++ b/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -42,11 +42,32 @@ function get_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) : DEFAULT_MIN_VALID_TIME; + let min_valid_time = config_time ? int(config_time) : get_system_min_valid_time_fallback(); let current_time = time(); return { diff --git a/applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js b/plugins/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js similarity index 100% rename from applications/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js rename to plugins/luci-app-2fa/root/www/luci-static/resources/view/plugins/bb4ea47fcffb44ec9bb3d3673c9b4ed2.js From a54c74dc2d2f0530f664da5c7dc797f0d42e6fc5 Mon Sep 17 00:00:00 2001 From: tokisaki galaxy Date: Sun, 5 Apr 2026 03:12:27 +0000 Subject: [PATCH 5/5] luci-app-2fa: native hex and more readable use native hex and base32 decoding functions Signed-off-by: tokisaki galaxy --- .../luci-app-2fa/root/usr/libexec/generate_otp.uc | 14 +++----------- .../auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc | 10 +++++----- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc b/plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc index 22b9358c641b..006e65d5b9ce 100755 --- a/plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc +++ b/plugins/luci-app-2fa/root/usr/libexec/generate_otp.uc @@ -6,14 +6,6 @@ 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; -} - const base32_decode_table = (function() { let t = {}; for (let i = 0; i < 26; i++) { t[ord('A') + i] = i; t[ord('a') + i] = i; } @@ -46,7 +38,7 @@ function decode_base32_to_bin(string) { function calculate_hmac_sha1(key, message) { const blocksize = 64; - if (length(key) > blocksize) key = hex_to_bin(sha1(key)); + if (length(key) > blocksize) key = hexdec(sha1(key)); while (length(key) < blocksize) key += chr(0); let o_key_pad = "", i_key_pad = ""; @@ -55,7 +47,7 @@ function calculate_hmac_sha1(key, message) { o_key_pad += chr(k ^ 0x5c); i_key_pad += chr(k ^ 0x36); } - let inner_hash = hex_to_bin(sha1(i_key_pad + message)); + let inner_hash = hexdec(sha1(i_key_pad + message)); return sha1(o_key_pad + inner_hash); } @@ -63,7 +55,7 @@ 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); + let counter_bin = pack(">Q", counter_int); let hmac_hex = calculate_hmac_sha1(secret_bin, counter_bin); diff --git a/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc b/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc index 7a7a6d3214bd..9af2d643baa5 100644 --- a/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc +++ b/plugins/luci-app-2fa/root/usr/share/ucode/luci/plugins/auth/login/bb4ea47fcffb44ec9bb3d3673c9b4ed2.uc @@ -658,12 +658,12 @@ return { 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", + 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 }; - } + return { success: true, whitelisted: true }; + } // Reserve rate limit attempt atomically if (client_ip) {