added mfa
This commit is contained in:
@@ -31,6 +31,8 @@ Change with `-setlogin` before first use.
|
||||
| `-certreset` | — | Remove stored cert, revert to self-signed |
|
||||
| `-log <path>` | `gotermix.log` next to binary | Auth log file path |
|
||||
| `-log off` | — | Disable file logging (console output always on) |
|
||||
| `-mfa <user> on` | — | Enable TOTP MFA for user — prints secret + QR code |
|
||||
| `-mfa <user> off` | — | Disable TOTP MFA for user |
|
||||
|
||||
---
|
||||
|
||||
@@ -97,6 +99,29 @@ Structured JSON-lines, one entry per login attempt:
|
||||
- Compatible with CrowdSec and fail2ban custom parsers
|
||||
- Console output always on; file output controlled by `-log`
|
||||
|
||||
## Run as service
|
||||
- `gotermix.service` is pretty limitted, you can change settings there to suit your needs
|
||||
```bash
|
||||
# 1. Create unprivileged system user (no shell, no home)
|
||||
useradd --system --no-create-home --shell /sbin/nologin gotermix
|
||||
|
||||
# 2. Deploy binary and set ownership
|
||||
mkdir -p /opt/gotermix
|
||||
cp gotermix /opt/gotermix/
|
||||
chown -R gotermix:gotermix /opt/gotermix
|
||||
chmod 750 /opt/gotermix
|
||||
chmod 750 /opt/gotermix/gotermix
|
||||
|
||||
# 3. Install and enable service
|
||||
cp gotermix.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now gotermix
|
||||
|
||||
# 4. Check it's up
|
||||
systemctl status gotermix
|
||||
journalctl -u gotermix -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
@@ -6,3 +6,5 @@ require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
|
||||
|
||||
@@ -2,3 +2,5 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
[Unit]
|
||||
Description=GoTermix — web terminal
|
||||
Documentation=https://ghb.freebede.com/nahakubuilder/gotermix
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Run as a dedicated unprivileged user.
|
||||
# Create it first:
|
||||
# useradd --system --no-create-home --shell /sbin/nologin gotermix
|
||||
User=gotermix
|
||||
Group=gotermix
|
||||
|
||||
# Working directory — binary, gws-creds.json and gotermix.log live here.
|
||||
WorkingDirectory=/opt/gotermix
|
||||
|
||||
# Absolute path to the binary.
|
||||
ExecStart=/opt/gotermix/gotermix -addr 0.0.0.0:5000
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# ── Hardening ────────────────────────────────────────────────────────
|
||||
# No new privileges beyond what the service user already has.
|
||||
NoNewPrivileges=true
|
||||
|
||||
# Private /tmp — isolates temp files from other services.
|
||||
PrivateTmp=true
|
||||
|
||||
# Read-only access to the real /usr, /boot, /etc.
|
||||
ProtectSystem=strict
|
||||
|
||||
# Allow the service to write its own data directory.
|
||||
ReadWritePaths=/opt/gotermix
|
||||
|
||||
# Hide /home and /root from the process.
|
||||
ProtectHome=true
|
||||
|
||||
# Prevent loading kernel modules.
|
||||
ProtectKernelModules=true
|
||||
|
||||
# Prevent writing to kernel tunables.
|
||||
ProtectKernelTunables=true
|
||||
|
||||
# Prevent altering control groups.
|
||||
ProtectControlGroups=true
|
||||
|
||||
# Allow only necessary syscall groups.
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
|
||||
# Restrict address families to IPv4/IPv6 (needed for HTTP listener + WebSocket).
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
# Deny ptrace and other debugging interfaces.
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=false
|
||||
# Note: MemoryDenyWriteExecute left off — Go runtime needs JIT-style writes.
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,43 @@
|
||||
[Unit]
|
||||
Description=GoTermix — web terminal
|
||||
Documentation=https://ghb.freebede.com/nahakubuilder/gotermix
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# ── User ─────────────────────────────────────────────────────────────
|
||||
# Option A (default): run as your own user so the terminal inherits
|
||||
# your permissions, sudo rights, and home directory.
|
||||
# Replace "youruser" with the actual username.
|
||||
User=youruser
|
||||
Group=youruser
|
||||
|
||||
# Option B: dedicated unprivileged system user (no sudo inside terminal).
|
||||
# Create first: useradd --system --no-create-home --shell /sbin/nologin gotermix
|
||||
# Then swap the User/Group lines above and remove NoNewPrivileges below.
|
||||
|
||||
# Working directory — binary, gws-creds.json and gotermix.log live here.
|
||||
WorkingDirectory=/opt/gotermix
|
||||
|
||||
# Absolute path to the binary.
|
||||
ExecStart=/opt/gotermix/gotermix -addr 0.0.0.0:5000
|
||||
|
||||
# Restart policy
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Kernel hardening — safe for both options.
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
|
||||
# Restrict address families to IPv4/IPv6/Unix sockets.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -28,6 +28,8 @@ type storedCreds struct {
|
||||
Username string `json:"username"`
|
||||
Salt string `json:"salt"`
|
||||
Hash string `json:"hash"`
|
||||
MFASecret string `json:"mfa_secret,omitempty"`
|
||||
MFAEnabled bool `json:"mfa_enabled,omitempty"`
|
||||
CertFile string `json:"cert_file,omitempty"`
|
||||
KeyFile string `json:"key_file,omitempty"`
|
||||
Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"`
|
||||
|
||||
+36
-16
@@ -158,8 +158,9 @@ func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
password := r.FormValue("password")
|
||||
totpCode := strings.TrimSpace(r.FormValue("totp_code"))
|
||||
|
||||
// Input bounds — reject obviously bad values before touching the hasher
|
||||
if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 {
|
||||
@@ -170,24 +171,43 @@ func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if checkCreds(username, password) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: makeAuthToken(),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(authTokenTTL.Seconds()),
|
||||
})
|
||||
logAuthAttempt(r, username, true, "login_success")
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
} else {
|
||||
time.Sleep(500 * time.Millisecond) // blunt brute-force deterrent
|
||||
// Step 1: verify password
|
||||
if !checkCreds(username, password) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
logAuthAttempt(r, username, false, "invalid_credentials")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: MFA (if enabled)
|
||||
if appCreds.MFAEnabled && appCreds.MFASecret != "" {
|
||||
if totpCode == "" {
|
||||
// Signal to frontend: show TOTP input
|
||||
w.Write([]byte(`{"mfa_required":true}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
if !validateTOTP(appCreds.MFASecret, totpCode) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
logAuthAttempt(r, username, false, "invalid_totp")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"Invalid authenticator code"}`)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed — issue auth cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authCookieName,
|
||||
Value: makeAuthToken(),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(authTokenTTL.Seconds()),
|
||||
})
|
||||
logAuthAttempt(r, username, true, "login_success")
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// Run is the application entry point, called from main().
|
||||
@@ -21,6 +24,7 @@ func Run() {
|
||||
cetkeyFlag := flag.String("certkey", "", "set custom TLS private key PEM file")
|
||||
certreset := flag.Bool("certreset", false, "remove stored custom certificate, revert to self-signed")
|
||||
logFlag := flag.String("log", "", "auth log file path; 'off' disables file logging (default: gotermix.log next to binary)")
|
||||
mfaFlag := flag.String("mfa", "", "manage MFA for a user: -mfa <username> on|off")
|
||||
flag.Parse()
|
||||
|
||||
initialCwd, _ = os.Getwd()
|
||||
@@ -91,6 +95,52 @@ func Run() {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// ── -mfa <username> on|off ────────────────────────────────────────
|
||||
if *mfaFlag != "" {
|
||||
args := flag.Args()
|
||||
if len(args) < 1 || (args[0] != "on" && args[0] != "off") {
|
||||
fmt.Fprintln(os.Stderr, "usage: -mfa <username> on|off")
|
||||
os.Exit(1)
|
||||
}
|
||||
c := loadCreds()
|
||||
if !strings.EqualFold(c.Username, *mfaFlag) {
|
||||
fmt.Fprintf(os.Stderr, "unknown user %q (current user is %q)\n", *mfaFlag, c.Username)
|
||||
os.Exit(1)
|
||||
}
|
||||
if args[0] == "off" {
|
||||
c.MFAEnabled = false
|
||||
c.MFASecret = ""
|
||||
if err := saveCreds(c); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("MFA disabled for user %q\n", c.Username)
|
||||
os.Exit(0)
|
||||
}
|
||||
// on: generate new secret
|
||||
secret := newTOTPSecret()
|
||||
c.MFASecret = secret
|
||||
c.MFAEnabled = true
|
||||
if err := saveCreds(c); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error saving credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
otpauthURL := totpOtpauthURL(secret, c.Username, "GoTermix")
|
||||
fmt.Printf("MFA enabled for user %q\n\n", c.Username)
|
||||
fmt.Printf("Secret (manual entry): %s\n\n", secret)
|
||||
fmt.Printf("otpauth URL: %s\n\n", otpauthURL)
|
||||
fmt.Println("Scan with your authenticator app (Google Authenticator, Aegis, Authy ...):")
|
||||
fmt.Println()
|
||||
qr, err := qrcode.New(otpauthURL, qrcode.Medium)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "QR error: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(qr.ToSmallString(false))
|
||||
}
|
||||
fmt.Println("If the QR code does not render, enter the secret manually in your app.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
nopwMode = *nopw
|
||||
appCreds = loadCreds()
|
||||
initAuthSecret()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package internals
|
||||
|
||||
// TOTP implementation per RFC 6238 (TOTP) and RFC 4226 (HOTP).
|
||||
// No external dependencies — uses only crypto/hmac, crypto/sha1, encoding/base32.
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
totpDigits = 6
|
||||
totpStep = 30 // seconds per time window
|
||||
totpWindow = 1 // ±1 step tolerance for clock drift (~30 s either side)
|
||||
)
|
||||
|
||||
// newTOTPSecret generates a random 160-bit (20-byte) base32 secret.
|
||||
// Compatible with Google Authenticator, Aegis, Authy, etc.
|
||||
func newTOTPSecret() string {
|
||||
b := make([]byte, 20)
|
||||
rand.Read(b) //nolint:errcheck
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
||||
}
|
||||
|
||||
// decodeTOTPSecret decodes a base32 secret; case-insensitive, padding optional.
|
||||
func decodeTOTPSecret(secret string) ([]byte, error) {
|
||||
s := strings.ToUpper(strings.TrimSpace(secret))
|
||||
if pad := len(s) % 8; pad != 0 {
|
||||
s += strings.Repeat("=", 8-pad)
|
||||
}
|
||||
return base32.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// hotpAt computes one HOTP value for the given key and counter (RFC 4226 §5).
|
||||
func hotpAt(key []byte, counter uint64) string {
|
||||
buf := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
mac := hmac.New(sha1.New, key)
|
||||
mac.Write(buf) //nolint:errcheck
|
||||
h := mac.Sum(nil)
|
||||
// Dynamic truncation
|
||||
offset := h[len(h)-1] & 0x0f
|
||||
code := (uint32(h[offset])&0x7f)<<24 |
|
||||
uint32(h[offset+1])<<16 |
|
||||
uint32(h[offset+2])<<8 |
|
||||
uint32(h[offset+3])
|
||||
code %= uint32(math.Pow10(totpDigits))
|
||||
return fmt.Sprintf("%0*d", totpDigits, code)
|
||||
}
|
||||
|
||||
// validateTOTP returns true if code matches the current window ± totpWindow steps.
|
||||
func validateTOTP(secret, code string) bool {
|
||||
if len(code) != totpDigits {
|
||||
return false
|
||||
}
|
||||
for _, c := range code {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
key, err := decodeTOTPSecret(secret)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
step := uint64(time.Now().Unix()) / totpStep
|
||||
for d := -totpWindow; d <= totpWindow; d++ {
|
||||
if hotpAt(key, uint64(int64(step)+int64(d))) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// totpOtpauthURL returns the otpauth:// URI used to provision authenticator apps.
|
||||
func totpOtpauthURL(secret, username, issuer string) string {
|
||||
return fmt.Sprintf(
|
||||
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
|
||||
url.PathEscape(issuer),
|
||||
url.PathEscape(username),
|
||||
secret,
|
||||
url.QueryEscape(issuer),
|
||||
totpDigits,
|
||||
totpStep,
|
||||
)
|
||||
}
|
||||
+80
-17
@@ -7,8 +7,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
<style>
|
||||
/* login page overrides — no tab bar or toolbar offsets */
|
||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.totp-digits {
|
||||
letter-spacing: 0.3em; font-size: 20px; text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,19 +19,37 @@
|
||||
<div class="m-card" style="max-width:360px;width:100%;margin:16px;">
|
||||
<div class="auth-card">
|
||||
<div class="auth-logo"><em>>_</em> GoTermix</div>
|
||||
<div class="auth-sub">Authentication required</div>
|
||||
|
||||
<label class="m-label" for="fUser">Username</label>
|
||||
<input class="m-input" type="text" id="fUser"
|
||||
autofocus autocomplete="username"
|
||||
placeholder="username" spellcheck="false"
|
||||
maxlength="64">
|
||||
<!-- Step 1: credentials -->
|
||||
<div id="credSection">
|
||||
<div class="auth-sub">Authentication required</div>
|
||||
|
||||
<label class="m-label" for="fPass">Password</label>
|
||||
<input class="m-input" type="password" id="fPass"
|
||||
autocomplete="current-password"
|
||||
placeholder="password"
|
||||
maxlength="1024">
|
||||
<label class="m-label" for="fUser">Username</label>
|
||||
<input class="m-input" type="text" id="fUser"
|
||||
autofocus autocomplete="username"
|
||||
placeholder="username" spellcheck="false"
|
||||
maxlength="64">
|
||||
|
||||
<label class="m-label" for="fPass">Password</label>
|
||||
<input class="m-input" type="password" id="fPass"
|
||||
autocomplete="current-password"
|
||||
placeholder="password"
|
||||
maxlength="1024">
|
||||
</div>
|
||||
|
||||
<!-- Step 2: TOTP (hidden until server returns mfa_required) -->
|
||||
<div id="totpSection" style="display:none;">
|
||||
<div class="auth-sub">Two-factor authentication</div>
|
||||
<label class="m-label" for="fTOTP">Authenticator code</label>
|
||||
<input class="m-input totp-digits" type="text" id="fTOTP"
|
||||
inputmode="numeric" pattern="[0-9]{6}"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="000000"
|
||||
maxlength="6">
|
||||
<div style="font-size:11px;color:#4b5563;margin-bottom:14px;">
|
||||
Enter the 6-digit code from your authenticator app.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-err" id="authErr"></div>
|
||||
|
||||
@@ -43,27 +64,57 @@
|
||||
const CSRF_TOKEN = "[[CSRF_TOKEN]]";
|
||||
const NEXT = "[[NEXT]]";
|
||||
|
||||
let mfaRequired = false;
|
||||
let savedUsername = '';
|
||||
let savedPassword = '';
|
||||
|
||||
// ── Keyboard nav ─────────────────────────────────────────────────────
|
||||
document.getElementById('fUser').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') document.getElementById('fPass').focus();
|
||||
});
|
||||
document.getElementById('fPass').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
document.getElementById('fTOTP').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
// Auto-submit when 6 digits entered
|
||||
document.getElementById('fTOTP').addEventListener('input', e => {
|
||||
const v = e.target.value.replace(/\D/g, '');
|
||||
e.target.value = v;
|
||||
if (v.length === 6) doLogin();
|
||||
});
|
||||
|
||||
// ── Login flow ────────────────────────────────────────────────────────
|
||||
async function doLogin() {
|
||||
const btn = document.getElementById('authBtn');
|
||||
document.getElementById('authErr').classList.remove('show');
|
||||
|
||||
if (mfaRequired) {
|
||||
const code = document.getElementById('fTOTP').value.trim();
|
||||
if (code.length !== 6) { showErr('Enter 6-digit code'); return; }
|
||||
await submitAuth(savedUsername, savedPassword, code);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = document.getElementById('fUser').value.trim();
|
||||
const password = document.getElementById('fPass').value;
|
||||
const btn = document.getElementById('authBtn');
|
||||
|
||||
if (!username || !password) { showErr('Enter username and password'); return; }
|
||||
|
||||
savedUsername = username;
|
||||
savedPassword = password;
|
||||
await submitAuth(username, password, '');
|
||||
}
|
||||
|
||||
async function submitAuth(username, password, totpCode) {
|
||||
const btn = document.getElementById('authBtn');
|
||||
btn.disabled = true; btn.classList.add('busy');
|
||||
document.getElementById('authErr').classList.remove('show');
|
||||
|
||||
const form = new URLSearchParams();
|
||||
form.append('username', username);
|
||||
form.append('password', password);
|
||||
form.append('csrf_token', CSRF_TOKEN);
|
||||
if (totpCode) form.append('totp_code', totpCode);
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth', {
|
||||
@@ -74,8 +125,14 @@ async function doLogin() {
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
window.location.href = NEXT || '/';
|
||||
} else if (data.mfa_required) {
|
||||
showTOTPStep();
|
||||
} else {
|
||||
showErr(data.error || 'Authentication failed');
|
||||
if (mfaRequired) {
|
||||
document.getElementById('fTOTP').value = '';
|
||||
document.getElementById('fTOTP').focus();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
showErr('Network error — try again');
|
||||
@@ -84,11 +141,17 @@ async function doLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function showTOTPStep() {
|
||||
mfaRequired = true;
|
||||
document.getElementById('credSection').style.display = 'none';
|
||||
document.getElementById('totpSection').style.display = 'block';
|
||||
document.getElementById('authBtn').querySelector('.btn-text').textContent = 'Verify';
|
||||
setTimeout(() => document.getElementById('fTOTP').focus(), 60);
|
||||
}
|
||||
|
||||
function showErr(msg) {
|
||||
const e = document.getElementById('authErr');
|
||||
e.textContent = msg; e.classList.add('show');
|
||||
document.getElementById('fPass').value = '';
|
||||
document.getElementById('fPass').focus();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user