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 |
|
| `-certreset` | — | Remove stored cert, revert to self-signed |
|
||||||
| `-log <path>` | `gotermix.log` next to binary | Auth log file path |
|
| `-log <path>` | `gotermix.log` next to binary | Auth log file path |
|
||||||
| `-log off` | — | Disable file logging (console output always on) |
|
| `-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
|
- Compatible with CrowdSec and fail2ban custom parsers
|
||||||
- Console output always on; file output controlled by `-log`
|
- 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
|
## Keyboard shortcuts
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ require (
|
|||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/gorilla/websocket v1.5.3
|
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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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"`
|
Username string `json:"username"`
|
||||||
Salt string `json:"salt"`
|
Salt string `json:"salt"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
|
MFASecret string `json:"mfa_secret,omitempty"`
|
||||||
|
MFAEnabled bool `json:"mfa_enabled,omitempty"`
|
||||||
CertFile string `json:"cert_file,omitempty"`
|
CertFile string `json:"cert_file,omitempty"`
|
||||||
KeyFile string `json:"key_file,omitempty"`
|
KeyFile string `json:"key_file,omitempty"`
|
||||||
Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"`
|
Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"`
|
||||||
|
|||||||
+36
-16
@@ -158,8 +158,9 @@ func handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := strings.TrimSpace(r.FormValue("username"))
|
username := strings.TrimSpace(r.FormValue("username"))
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
totpCode := strings.TrimSpace(r.FormValue("totp_code"))
|
||||||
|
|
||||||
// Input bounds — reject obviously bad values before touching the hasher
|
// Input bounds — reject obviously bad values before touching the hasher
|
||||||
if len(username) == 0 || len(username) > 64 || len(password) == 0 || len(password) > 1024 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if checkCreds(username, password) {
|
// Step 1: verify password
|
||||||
http.SetCookie(w, &http.Cookie{
|
if !checkCreds(username, password) {
|
||||||
Name: authCookieName,
|
time.Sleep(500 * time.Millisecond)
|
||||||
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
|
|
||||||
logAuthAttempt(r, username, false, "invalid_credentials")
|
logAuthAttempt(r, username, false, "invalid_credentials")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.Write([]byte(`{"error":"Invalid username or password"}`)) //nolint:errcheck
|
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) {
|
func handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run is the application entry point, called from main().
|
// 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")
|
cetkeyFlag := flag.String("certkey", "", "set custom TLS private key PEM file")
|
||||||
certreset := flag.Bool("certreset", false, "remove stored custom certificate, revert to self-signed")
|
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)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
initialCwd, _ = os.Getwd()
|
initialCwd, _ = os.Getwd()
|
||||||
@@ -91,6 +95,52 @@ func Run() {
|
|||||||
os.Exit(0)
|
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
|
nopwMode = *nopw
|
||||||
appCreds = loadCreds()
|
appCreds = loadCreds()
|
||||||
initAuthSecret()
|
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="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="stylesheet" href="/static/app.css" />
|
<link rel="stylesheet" href="/static/app.css" />
|
||||||
<style>
|
<style>
|
||||||
/* login page overrides — no tab bar or toolbar offsets */
|
|
||||||
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -16,19 +19,37 @@
|
|||||||
<div class="m-card" style="max-width:360px;width:100%;margin:16px;">
|
<div class="m-card" style="max-width:360px;width:100%;margin:16px;">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo"><em>>_</em> GoTermix</div>
|
<div class="auth-logo"><em>>_</em> GoTermix</div>
|
||||||
<div class="auth-sub">Authentication required</div>
|
|
||||||
|
|
||||||
<label class="m-label" for="fUser">Username</label>
|
<!-- Step 1: credentials -->
|
||||||
<input class="m-input" type="text" id="fUser"
|
<div id="credSection">
|
||||||
autofocus autocomplete="username"
|
<div class="auth-sub">Authentication required</div>
|
||||||
placeholder="username" spellcheck="false"
|
|
||||||
maxlength="64">
|
|
||||||
|
|
||||||
<label class="m-label" for="fPass">Password</label>
|
<label class="m-label" for="fUser">Username</label>
|
||||||
<input class="m-input" type="password" id="fPass"
|
<input class="m-input" type="text" id="fUser"
|
||||||
autocomplete="current-password"
|
autofocus autocomplete="username"
|
||||||
placeholder="password"
|
placeholder="username" spellcheck="false"
|
||||||
maxlength="1024">
|
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>
|
<div class="auth-err" id="authErr"></div>
|
||||||
|
|
||||||
@@ -43,27 +64,57 @@
|
|||||||
const CSRF_TOKEN = "[[CSRF_TOKEN]]";
|
const CSRF_TOKEN = "[[CSRF_TOKEN]]";
|
||||||
const NEXT = "[[NEXT]]";
|
const NEXT = "[[NEXT]]";
|
||||||
|
|
||||||
|
let mfaRequired = false;
|
||||||
|
let savedUsername = '';
|
||||||
|
let savedPassword = '';
|
||||||
|
|
||||||
|
// ── Keyboard nav ─────────────────────────────────────────────────────
|
||||||
document.getElementById('fUser').addEventListener('keydown', e => {
|
document.getElementById('fUser').addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') document.getElementById('fPass').focus();
|
if (e.key === 'Enter') document.getElementById('fPass').focus();
|
||||||
});
|
});
|
||||||
document.getElementById('fPass').addEventListener('keydown', e => {
|
document.getElementById('fPass').addEventListener('keydown', e => {
|
||||||
if (e.key === 'Enter') doLogin();
|
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() {
|
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 username = document.getElementById('fUser').value.trim();
|
||||||
const password = document.getElementById('fPass').value;
|
const password = document.getElementById('fPass').value;
|
||||||
const btn = document.getElementById('authBtn');
|
|
||||||
|
|
||||||
if (!username || !password) { showErr('Enter username and password'); return; }
|
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');
|
btn.disabled = true; btn.classList.add('busy');
|
||||||
document.getElementById('authErr').classList.remove('show');
|
|
||||||
|
|
||||||
const form = new URLSearchParams();
|
const form = new URLSearchParams();
|
||||||
form.append('username', username);
|
form.append('username', username);
|
||||||
form.append('password', password);
|
form.append('password', password);
|
||||||
form.append('csrf_token', CSRF_TOKEN);
|
form.append('csrf_token', CSRF_TOKEN);
|
||||||
|
if (totpCode) form.append('totp_code', totpCode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/auth', {
|
const res = await fetch('/auth', {
|
||||||
@@ -74,8 +125,14 @@ async function doLogin() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
window.location.href = NEXT || '/';
|
window.location.href = NEXT || '/';
|
||||||
|
} else if (data.mfa_required) {
|
||||||
|
showTOTPStep();
|
||||||
} else {
|
} else {
|
||||||
showErr(data.error || 'Authentication failed');
|
showErr(data.error || 'Authentication failed');
|
||||||
|
if (mfaRequired) {
|
||||||
|
document.getElementById('fTOTP').value = '';
|
||||||
|
document.getElementById('fTOTP').focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
showErr('Network error — try again');
|
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) {
|
function showErr(msg) {
|
||||||
const e = document.getElementById('authErr');
|
const e = document.getElementById('authErr');
|
||||||
e.textContent = msg; e.classList.add('show');
|
e.textContent = msg; e.classList.add('show');
|
||||||
document.getElementById('fPass').value = '';
|
|
||||||
document.getElementById('fPass').focus();
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user