added mfa

This commit is contained in:
2026-05-24 08:37:27 +00:00
parent a1d9ed86e1
commit 3ab54f812a
10 changed files with 398 additions and 33 deletions
+25
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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=
+65
View File
@@ -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
+43
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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) {
+50
View File
@@ -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()
+93
View File
@@ -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
View File
@@ -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>&gt;_</em> GoTermix</div> <div class="auth-logo"><em>&gt;_</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>