From 34bc8f76f9b94a58e043eb799f3003a8b1a5e28f Mon Sep 17 00:00:00 2001
From: sysops
Date: Sat, 25 Jul 2026 00:42:05 +0200
Subject: [PATCH] Add per-user TOTP 2FA, client-level user assignment,
self-service portal
- TOTP (RFC 6238, stdlib-only) enrollment in profile, login step-up,
admin emergency reset.
- Admins can grant a user visibility into individual clients
(User.ClientIDs) in addition to whole-server access (User.ServerIDs).
- New "My Access" page: non-admin users see only their assigned clients
(view/QR/download only, no management), reachable from the main nav.
- GetUser/GetUsers now redact TOTPSecret before returning JSON.
No Go toolchain was available while writing this - not yet build-verified.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
---
DEVLOG.md | 80 +++++++++
auth/totp.go | 148 +++++++++++++++++
auth/totp_test.go | 85 ++++++++++
handler/routes.go | 297 ++++++++++++++++++++++++++++++----
handler/routes_my_access.go | 263 ++++++++++++++++++++++++++++++
main.go | 8 +
model/user.go | 10 ++
templates/base.html | 8 +
templates/login.html | 53 +++++-
templates/my_access.html | 122 ++++++++++++++
templates/profile.html | 129 +++++++++++++++
templates/users_settings.html | 46 +++++-
12 files changed, 1213 insertions(+), 36 deletions(-)
create mode 100644 auth/totp.go
create mode 100644 auth/totp_test.go
create mode 100644 handler/routes_my_access.go
create mode 100644 templates/my_access.html
diff --git a/DEVLOG.md b/DEVLOG.md
index c0cbabf..20a8911 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -2439,3 +2439,83 @@ Keine Commits in dieser Session.
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
---
+## 2026-07-25 00:18 – 00:19 (0m)
+**Beschreibung:** Claude Code Session
+**Projekt:** wireguard-ui-multi
+
+### Commits
+- c29edfd Add wg-quick/systemctl service control and bulk-delete list views
+
+### Geänderte Dateien
+- DEVLOG.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- handler/routes.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++
+- main.go | 4 +++
+- templates/server_clients.html | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
+- templates/servers.html | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- wireguard/service.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++
+
+---
+## 2026-07-25 00:21 – 00:21 (0m)
+**Beschreibung:** Claude Code Session
+**Projekt:** wireguard-ui-multi
+
+### Commits
+Keine Commits in dieser Session.
+
+### Geänderte Dateien
+- DEVLOG.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- handler/routes.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++
+- main.go | 4 +++
+- templates/server_clients.html | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
+- templates/servers.html | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- wireguard/service.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++
+
+---
+## 2026-07-25 00:28 – 00:33 (5m)
+**Beschreibung:** Claude Code Session
+**Projekt:** wireguard-ui-multi
+
+### Commits
+Keine Commits in dieser Session.
+
+### Geänderte Dateien
+- DEVLOG.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- handler/routes.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++
+- main.go | 4 +++
+- templates/server_clients.html | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
+- templates/servers.html | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- wireguard/service.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++
+
+---
+## 2026-07-25 00:34 – 00:34 (0m)
+**Beschreibung:** Claude Code Session
+**Projekt:** wireguard-ui-multi
+
+### Commits
+Keine Commits in dieser Session.
+
+### Geänderte Dateien
+- DEVLOG.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- handler/routes.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++
+- main.go | 4 +++
+- templates/server_clients.html | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
+- templates/servers.html | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- wireguard/service.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++
+
+---
+## 2026-07-25 00:35 – 00:35 (0m)
+**Beschreibung:** Claude Code Session
+**Projekt:** wireguard-ui-multi
+
+### Commits
+Keine Commits in dieser Session.
+
+### Geänderte Dateien
+- DEVLOG.md | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- handler/routes.go | 84 +++++++++++++++++++++++++++++++++++++++++++++++
+- main.go | 4 +++
+- templates/server_clients.html | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
+- templates/servers.html | 274 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+- wireguard/service.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++++
+
+---
diff --git a/auth/totp.go b/auth/totp.go
new file mode 100644
index 0000000..0031b91
--- /dev/null
+++ b/auth/totp.go
@@ -0,0 +1,148 @@
+// Package auth implements a minimal, dependency-free TOTP (RFC 6238) /
+// HOTP (RFC 4226) implementation used for per-user two-factor login.
+//
+// Only the Go standard library is used so that adding 2FA support does not
+// require any go.mod/go.sum changes.
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha1"
+ "crypto/subtle"
+ "encoding/base32"
+ "encoding/binary"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ // totpPeriod is the standard TOTP time step, in seconds.
+ totpPeriod = 30
+ // totpDigits is the number of digits in the generated code.
+ totpDigits = 6
+ // secretSize is the number of random bytes used to build a secret,
+ // matching the common 160-bit (20 byte) recommendation for HMAC-SHA1.
+ secretSize = 20
+)
+
+// base32Encoding encodes/decodes secrets without padding, using the
+// standard (uppercase) RFC 4648 base32 alphabet, matching the otpauth
+// convention used by authenticator apps.
+var base32Encoding = base32.StdEncoding.WithPadding(base32.NoPadding)
+
+// GenerateSecret creates a new random, base32-encoded TOTP secret.
+func GenerateSecret() (string, error) {
+ buf := make([]byte, secretSize)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("cannot generate random secret: %w", err)
+ }
+ return strings.ToUpper(base32Encoding.EncodeToString(buf)), nil
+}
+
+// generateCodeAtCounter computes the HOTP code for a given counter value,
+// per RFC 4226.
+func generateCodeAtCounter(secretBase32 string, counter uint64) (string, error) {
+ secret, err := decodeSecret(secretBase32)
+ if err != nil {
+ return "", err
+ }
+
+ msg := make([]byte, 8)
+ binary.BigEndian.PutUint64(msg, counter)
+
+ mac := hmac.New(sha1.New, secret)
+ mac.Write(msg)
+ sum := mac.Sum(nil)
+
+ offset := sum[len(sum)-1] & 0x0f
+ truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
+
+ mod := uint32(1)
+ for i := 0; i < totpDigits; i++ {
+ mod *= 10
+ }
+ code := truncated % mod
+
+ return fmt.Sprintf("%0*d", totpDigits, code), nil
+}
+
+// decodeSecret normalizes and base32-decodes a secret string.
+func decodeSecret(secretBase32 string) ([]byte, error) {
+ clean := strings.ToUpper(strings.TrimSpace(secretBase32))
+ clean = strings.ReplaceAll(clean, " ", "")
+ secret, err := base32Encoding.DecodeString(clean)
+ if err != nil {
+ return nil, fmt.Errorf("invalid totp secret: %w", err)
+ }
+ return secret, nil
+}
+
+// counterAt returns the TOTP counter value for the given time.
+func counterAt(t time.Time) uint64 {
+ return uint64(t.Unix() / totpPeriod)
+}
+
+// GenerateCode returns the 6-digit TOTP code for secretBase32 valid at time t.
+func GenerateCode(secretBase32 string, t time.Time) (string, error) {
+ return generateCodeAtCounter(secretBase32, counterAt(t))
+}
+
+// Validate checks whether code is a valid TOTP code for secretBase32 at
+// time t, allowing +/- one 30-second step of clock skew tolerance. The
+// final comparison is constant-time.
+func Validate(secretBase32, code string, t time.Time) bool {
+ code = strings.TrimSpace(code)
+ if len(code) != totpDigits {
+ return false
+ }
+
+ counter := counterAt(t)
+ // Check current step first, then the adjacent steps (skew tolerance).
+ for _, delta := range []int64{0, -1, 1} {
+ c := counter
+ if delta < 0 {
+ if c == 0 {
+ continue
+ }
+ c--
+ } else if delta > 0 {
+ c++
+ }
+
+ expected, err := generateCodeAtCounter(secretBase32, c)
+ if err != nil {
+ return false
+ }
+
+ if subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 {
+ return true
+ }
+ }
+
+ return false
+}
+
+// ProvisioningURI builds an otpauth:// URI suitable for encoding into a QR
+// code and scanning with any standard authenticator app.
+func ProvisioningURI(secretBase32, accountName, issuer string) string {
+ label := fmt.Sprintf("%s:%s", issuer, accountName)
+
+ u := url.URL{
+ Scheme: "otpauth",
+ Host: "totp",
+ Path: "/" + label,
+ }
+
+ q := url.Values{}
+ q.Set("secret", secretBase32)
+ q.Set("issuer", issuer)
+ q.Set("algorithm", "SHA1")
+ q.Set("digits", fmt.Sprintf("%d", totpDigits))
+ q.Set("period", fmt.Sprintf("%d", totpPeriod))
+ u.RawQuery = q.Encode()
+
+ return u.String()
+}
diff --git a/auth/totp_test.go b/auth/totp_test.go
new file mode 100644
index 0000000..74667b1
--- /dev/null
+++ b/auth/totp_test.go
@@ -0,0 +1,85 @@
+package auth
+
+import (
+ "testing"
+ "time"
+)
+
+// TestRoundTrip verifies that a code generated for a given secret/time
+// validates successfully against that same secret/time. We deliberately
+// don't chase the exact RFC 6238 8-digit test vector digits here since
+// this implementation standardizes on 6-digit codes; self-consistency of
+// generate -> validate is what matters for correctness of our HOTP/TOTP
+// math and step handling.
+func TestRoundTrip(t *testing.T) {
+ secret, err := GenerateSecret()
+ if err != nil {
+ t.Fatalf("GenerateSecret failed: %v", err)
+ }
+
+ // Fixed reference time so the test is deterministic (corresponds to
+ // RFC 6238's T=59 test instant).
+ refTime := time.Unix(59, 0).UTC()
+
+ code, err := GenerateCode(secret, refTime)
+ if err != nil {
+ t.Fatalf("GenerateCode failed: %v", err)
+ }
+
+ if len(code) != 6 {
+ t.Fatalf("expected 6-digit code, got %q", code)
+ }
+
+ if !Validate(secret, code, refTime) {
+ t.Fatalf("Validate failed to accept code %q generated for the same secret/time", code)
+ }
+}
+
+func TestWrongCodeRejected(t *testing.T) {
+ secret, err := GenerateSecret()
+ if err != nil {
+ t.Fatalf("GenerateSecret failed: %v", err)
+ }
+
+ refTime := time.Unix(59, 0).UTC()
+
+ code, err := GenerateCode(secret, refTime)
+ if err != nil {
+ t.Fatalf("GenerateCode failed: %v", err)
+ }
+
+ wrong := "000000"
+ if code == wrong {
+ wrong = "111111"
+ }
+
+ if Validate(secret, wrong, refTime) {
+ t.Fatalf("Validate incorrectly accepted a wrong code")
+ }
+}
+
+func TestClockSkewToleranceAndRejection(t *testing.T) {
+ secret, err := GenerateSecret()
+ if err != nil {
+ t.Fatalf("GenerateSecret failed: %v", err)
+ }
+
+ refTime := time.Unix(1_000_000, 0).UTC()
+ code, err := GenerateCode(secret, refTime)
+ if err != nil {
+ t.Fatalf("GenerateCode failed: %v", err)
+ }
+
+ // One step (30s) away should still validate (skew tolerance).
+ oneStepLater := refTime.Add(30 * time.Second)
+ if !Validate(secret, code, oneStepLater) {
+ t.Fatalf("Validate should tolerate +-1 step (30s) of clock skew")
+ }
+
+ // Two steps (60s, i.e. > 1 step tolerance) away should NOT validate.
+ // Use 120s to be unambiguous with respect to step boundaries.
+ farLater := refTime.Add(120 * time.Second)
+ if Validate(secret, code, farLater) {
+ t.Fatalf("Validate should reject a code more than 1 step (30s) away")
+ }
+}
diff --git a/handler/routes.go b/handler/routes.go
index bbc7bf2..2f5221b 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -24,6 +24,7 @@ import (
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "github.com/ngoduykhanh/wireguard-ui/auth"
"github.com/ngoduykhanh/wireguard-ui/backup"
"github.com/ngoduykhanh/wireguard-ui/emailer"
"github.com/ngoduykhanh/wireguard-ui/firewall"
@@ -135,43 +136,30 @@ func Login(db store.IStore) echo.HandlerFunc {
}
if userCorrect && passwordCorrect {
- ageMax := 0
- if rememberMe {
- ageMax = 86400 * 7
+ if dbuser.TOTPEnabled {
+ // Password check passed, but this account requires a second
+ // factor. Stash a short-lived, NOT-yet-authenticated pending
+ // state instead of finalizing the session.
+ cookiePath := util.GetCookiePath()
+
+ sess, _ := session.Get("session", c)
+ sess.Options = &sessions.Options{
+ Path: cookiePath,
+ MaxAge: 300, // 5 minutes to enter the TOTP code
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ }
+ sess.Values["pending_totp_user"] = dbuser.Username
+ sess.Values["pending_remember_me"] = rememberMe
+ sess.Save(c.Request(), c.Response())
+
+ return c.JSON(http.StatusOK, map[string]interface{}{"success": true, "totp_required": true})
}
- cookiePath := util.GetCookiePath()
-
- sess, _ := session.Get("session", c)
- sess.Options = &sessions.Options{
- Path: cookiePath,
- MaxAge: ageMax,
- HttpOnly: true,
- SameSite: http.SameSiteLaxMode,
+ if err := finalizeLoginSession(c, dbuser, rememberMe); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
- // set session_token
- tokenUID := xid.New().String()
- now := time.Now().UTC().Unix()
- sess.Values["username"] = dbuser.Username
- sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser)
- sess.Values["admin"] = dbuser.Admin
- sess.Values["session_token"] = tokenUID
- sess.Values["max_age"] = ageMax
- sess.Values["created_at"] = now
- sess.Values["updated_at"] = now
- sess.Save(c.Request(), c.Response())
-
- // set session_token in cookie
- cookie := new(http.Cookie)
- cookie.Name = "session_token"
- cookie.Path = cookiePath
- cookie.Value = tokenUID
- cookie.MaxAge = ageMax
- cookie.HttpOnly = true
- cookie.SameSite = http.SameSiteLaxMode
- c.SetCookie(cookie)
-
log.Infof("Logged in successfully user %s (%s)", username, c.Request().RemoteAddr)
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
}
@@ -181,6 +169,98 @@ func Login(db store.IStore) echo.HandlerFunc {
}
}
+// finalizeLoginSession fully authenticates dbuser by writing the real
+// session values and the session_token cookie. It is shared between the
+// no-TOTP path of Login and the second-factor confirmation in
+// VerifyTOTPLogin so the two code paths cannot drift apart.
+func finalizeLoginSession(c echo.Context, dbuser model.User, rememberMe bool) error {
+ ageMax := 0
+ if rememberMe {
+ ageMax = 86400 * 7
+ }
+
+ cookiePath := util.GetCookiePath()
+
+ sess, _ := session.Get("session", c)
+ sess.Options = &sessions.Options{
+ Path: cookiePath,
+ MaxAge: ageMax,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ }
+
+ // set session_token
+ tokenUID := xid.New().String()
+ now := time.Now().UTC().Unix()
+ sess.Values["username"] = dbuser.Username
+ sess.Values["user_hash"] = util.GetDBUserCRC32(dbuser)
+ sess.Values["admin"] = dbuser.Admin
+ sess.Values["session_token"] = tokenUID
+ sess.Values["max_age"] = ageMax
+ sess.Values["created_at"] = now
+ sess.Values["updated_at"] = now
+ if err := sess.Save(c.Request(), c.Response()); err != nil {
+ return err
+ }
+
+ // set session_token in cookie
+ cookie := new(http.Cookie)
+ cookie.Name = "session_token"
+ cookie.Path = cookiePath
+ cookie.Value = tokenUID
+ cookie.MaxAge = ageMax
+ cookie.HttpOnly = true
+ cookie.SameSite = http.SameSiteLaxMode
+ c.SetCookie(cookie)
+
+ return nil
+}
+
+// VerifyTOTPLogin completes a login that was put into the "pending TOTP"
+// state by Login, after the user supplies a valid 6-digit code.
+func VerifyTOTPLogin(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ sess, _ := session.Get("session", c)
+
+ pendingUsername, ok := sess.Values["pending_totp_user"].(string)
+ if !ok || pendingUsername == "" {
+ return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "No pending login"})
+ }
+
+ rememberMe, _ := sess.Values["pending_remember_me"].(bool)
+
+ data := make(map[string]interface{})
+ if err := json.NewDecoder(c.Request().Body).Decode(&data); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+ }
+
+ code, _ := data["code"].(string)
+
+ dbuser, err := db.GetUserByName(pendingUsername)
+ if err != nil {
+ log.Warnf("Invalid pending TOTP login. Cannot query user %s from DB (%s)", pendingUsername, c.Request().RemoteAddr)
+ return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "No pending login"})
+ }
+
+ if !dbuser.TOTPEnabled || !auth.Validate(dbuser.TOTPSecret, code, time.Now()) {
+ log.Warnf("Invalid TOTP code for user %s (%s)", pendingUsername, c.Request().RemoteAddr)
+ return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
+ }
+
+ // clear the pending state before finalizing the real session
+ delete(sess.Values, "pending_totp_user")
+ delete(sess.Values, "pending_remember_me")
+ sess.Save(c.Request(), c.Response())
+
+ if err := finalizeLoginSession(c, dbuser, rememberMe); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+
+ log.Infof("Logged in successfully user %s (%s)", pendingUsername, c.Request().RemoteAddr)
+ return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
+ }
+}
+
// GetUsers handler return a JSON list of all users
func GetUsers(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -191,6 +271,11 @@ func GetUsers(db store.IStore) echo.HandlerFunc {
})
}
+ // never expose live TOTP secrets to API callers
+ for i := range usersList {
+ usersList[i].TOTPSecret = ""
+ }
+
return c.JSON(http.StatusOK, usersList)
}
}
@@ -213,6 +298,10 @@ func GetUser(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
}
+ // Never expose the raw TOTP secret through this endpoint. Enrollment
+ // has its own dedicated, self-only route (/profile/totp/enroll).
+ userData.TOTPSecret = ""
+
return c.JSON(http.StatusOK, userData)
}
}
@@ -309,7 +398,7 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
user.Admin = admin
}
- // only an admin may change which servers a user can access
+ // only an admin may change which servers/clients a user can access
if isAdmin(c) {
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
serverIDs := make([]string, 0, len(rawIDs))
@@ -320,6 +409,16 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
}
user.ServerIDs = serverIDs
}
+
+ if rawIDs, ok := data["client_ids"].([]interface{}); ok {
+ clientIDs := make([]string, 0, len(rawIDs))
+ for _, v := range rawIDs {
+ if id, ok := v.(string); ok && util.ValidateRecordID(id) {
+ clientIDs = append(clientIDs, id)
+ }
+ }
+ user.ClientIDs = clientIDs
+ }
}
if err := db.DeleteUser(previousUsername); err != nil {
@@ -338,6 +437,128 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
}
}
+// totpIssuer is the "issuer" name embedded in the otpauth:// provisioning
+// URI, shown by authenticator apps next to the account name.
+const totpIssuer = "WireGuard-UI-Multi"
+
+// EnrollTOTP starts (or resumes) self-service TOTP enrollment for the
+// currently logged-in user. It generates a secret on first call (leaving
+// TOTPEnabled false until confirmed via ConfirmTOTP) and always returns a
+// QR code / provisioning secret for the currently stored (unconfirmed or
+// confirmed) secret.
+func EnrollTOTP(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ username := currentUser(c)
+
+ user, err := db.GetUserByName(username)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
+ }
+
+ if user.TOTPSecret == "" {
+ secret, err := auth.GenerateSecret()
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate TOTP secret: " + err.Error()})
+ }
+ user.TOTPSecret = secret
+ user.TOTPEnabled = false
+ if err := db.SaveUser(user); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ }
+
+ uri := auth.ProvisioningURI(user.TOTPSecret, user.Username, totpIssuer)
+
+ png, err := qrcode.Encode(uri, qrcode.Medium, 256)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "qr gen: " + err.Error()})
+ }
+ qrDataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
+
+ return c.JSON(http.StatusOK, map[string]interface{}{
+ "secret": user.TOTPSecret,
+ "qrcode": qrDataURI,
+ "enabled": user.TOTPEnabled,
+ })
+ }
+}
+
+// ConfirmTOTP verifies a code against the currently pending (unconfirmed)
+// TOTP secret for the logged-in user, and enables TOTP on success.
+func ConfirmTOTP(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ username := currentUser(c)
+
+ data := make(map[string]interface{})
+ if err := json.NewDecoder(c.Request().Body).Decode(&data); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+ }
+ code, _ := data["code"].(string)
+
+ user, err := db.GetUserByName(username)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
+ }
+
+ if user.TOTPSecret == "" {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "TOTP enrollment has not been started"})
+ }
+
+ if !auth.Validate(user.TOTPSecret, code, time.Now()) {
+ log.Warnf("Invalid TOTP confirmation code for user %s (%s)", username, c.Request().RemoteAddr)
+ return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
+ }
+
+ user.TOTPEnabled = true
+ if err := db.SaveUser(user); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+
+ log.Infof("User %s enabled two-factor authentication", username)
+ return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Two-factor authentication enabled"})
+ }
+}
+
+// DisableTOTP disables TOTP for the current user, or - if the caller is an
+// admin and explicitly names a different user - resets that other user's
+// TOTP as an emergency lockout recovery mechanism.
+func DisableTOTP(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ username := currentUser(c)
+
+ data := make(map[string]interface{})
+ // Body is optional for the self-service case.
+ _ = json.NewDecoder(c.Request().Body).Decode(&data)
+
+ targetUsername := username
+ if requested, ok := data["username"].(string); ok && requested != "" {
+ if isAdmin(c) && requested != currentUser(c) {
+ targetUsername = requested
+ } else if requested != currentUser(c) {
+ return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
+ }
+ }
+
+ if !usernameRegexp.MatchString(targetUsername) {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
+ }
+
+ user, err := db.GetUserByName(targetUsername)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "User not found"})
+ }
+
+ user.TOTPSecret = ""
+ user.TOTPEnabled = false
+ if err := db.SaveUser(user); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+
+ log.Infof("Two-factor authentication disabled for user %s (by %s)", targetUsername, username)
+ return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Two-factor authentication disabled"})
+ }
+}
+
// CreateUser to create new user
func CreateUser(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -382,6 +603,14 @@ func CreateUser(db store.IStore) echo.HandlerFunc {
}
}
+ if rawIDs, ok := data["client_ids"].([]interface{}); ok {
+ for _, v := range rawIDs {
+ if id, ok := v.(string); ok && util.ValidateRecordID(id) {
+ user.ClientIDs = append(user.ClientIDs, id)
+ }
+ }
+ }
+
if err := db.SaveUser(user); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
}
diff --git a/handler/routes_my_access.go b/handler/routes_my_access.go
new file mode 100644
index 0000000..52fa15f
--- /dev/null
+++ b/handler/routes_my_access.go
@@ -0,0 +1,263 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/labstack/gommon/log"
+ "github.com/ngoduykhanh/wireguard-ui/model"
+ "github.com/ngoduykhanh/wireguard-ui/store"
+ "github.com/ngoduykhanh/wireguard-ui/util"
+ "github.com/rs/xid"
+)
+
+// MyAccessClientView is the display-friendly shape returned to the
+// "My Access" self-service portal page - it deliberately omits any secret
+// key material (private key, preshared key) since non-admins must never
+// see those in the frontend.
+type MyAccessClientView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ ServerID string `json:"server_id"`
+ ServerName string `json:"server_name"`
+ Interface string `json:"interface"`
+ AllocatedIPs []string `json:"allocated_ips"`
+ Enabled bool `json:"enabled"`
+}
+
+// visibleClientsForUser computes the set of clients a given (possibly
+// non-admin) user is allowed to see on the self-service "My Access" portal:
+//
+// (clients whose ServerID is in user.ServerIDs) UNION (clients whose own
+// ID is in user.ClientIDs)
+//
+// Admins bypass the filter entirely and see every client.
+func visibleClientsForUser(db store.IStore, user model.User, admin bool) ([]model.ClientData, error) {
+ allClients, err := db.GetClients(false)
+ if err != nil {
+ return nil, err
+ }
+
+ if admin {
+ return allClients, nil
+ }
+
+ allowedServers := make(map[string]bool, len(user.ServerIDs))
+ for _, id := range user.ServerIDs {
+ allowedServers[id] = true
+ }
+ allowedClients := make(map[string]bool, len(user.ClientIDs))
+ for _, id := range user.ClientIDs {
+ allowedClients[id] = true
+ }
+
+ var visible []model.ClientData
+ for _, cd := range allClients {
+ if allowedServers[cd.Client.ServerID] || allowedClients[cd.Client.ID] {
+ visible = append(visible, cd)
+ }
+ }
+ return visible, nil
+}
+
+// clientAuthorizedForUser reports whether the given user (admin or not) may
+// access the single client identified by clientData, per the same union
+// rule used by visibleClientsForUser.
+func clientAuthorizedForUser(user model.User, admin bool, clientData model.ClientData) bool {
+ if admin {
+ return true
+ }
+ for _, id := range user.ServerIDs {
+ if id == clientData.Client.ServerID {
+ return true
+ }
+ }
+ for _, id := range user.ClientIDs {
+ if id == clientData.Client.ID {
+ return true
+ }
+ }
+ return false
+}
+
+// buildMyAccessViews resolves the server name/interface for each visible
+// client and shapes them for display, skipping any client whose server
+// cannot be resolved anymore (orphaned record).
+func buildMyAccessViews(db store.IStore, clientDataList []model.ClientData) []MyAccessClientView {
+ serverCache := make(map[string]model.Server)
+ views := make([]MyAccessClientView, 0, len(clientDataList))
+
+ for _, cd := range clientDataList {
+ serverID := cd.Client.ServerID
+ if serverID == "" {
+ serverID = util.DefaultServerID
+ }
+
+ server, ok := serverCache[serverID]
+ if !ok {
+ s, err := db.GetServerByID(serverID)
+ if err != nil {
+ continue
+ }
+ server = s
+ serverCache[serverID] = server
+ }
+
+ interfaceName := ""
+ if server.Interface != nil {
+ interfaceName = server.Interface.Name
+ }
+
+ views = append(views, MyAccessClientView{
+ ID: cd.Client.ID,
+ Name: cd.Client.Name,
+ Email: cd.Client.Email,
+ ServerID: serverID,
+ ServerName: server.Name,
+ Interface: interfaceName,
+ AllocatedIPs: cd.Client.AllocatedIPs,
+ Enabled: cd.Client.Enabled,
+ })
+ }
+
+ return views
+}
+
+// MyAccessPage renders the read-only, per-user self-service portal: a
+// non-admin sees only their own assigned clients (via ServerIDs/ClientIDs),
+// an admin sees every client. View + QR code + download only - no
+// add/edit/delete controls live on this page.
+func MyAccessPage(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ admin := isAdmin(c)
+
+ user, err := db.GetUserByName(currentUser(c))
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"})
+ }
+
+ clientDataList, err := visibleClientsForUser(db, user, admin)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
+ false, fmt.Sprintf("Cannot get client list: %v", err),
+ })
+ }
+
+ views := buildMyAccessViews(db, clientDataList)
+
+ return c.Render(http.StatusOK, "my_access.html", map[string]interface{}{
+ "baseData": model.BaseData{Active: "my-access", CurrentUser: currentUser(c), Admin: admin},
+ "clients": views,
+ })
+ }
+}
+
+// GetMyAccessClients returns the same filtered listing as JSON, for the
+// page's client-side refresh (e.g. after a status change elsewhere).
+func GetMyAccessClients(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ admin := isAdmin(c)
+
+ user, err := db.GetUserByName(currentUser(c))
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"})
+ }
+
+ clientDataList, err := visibleClientsForUser(db, user, admin)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
+ false, fmt.Sprintf("Cannot get client list: %v", err),
+ })
+ }
+
+ return c.JSON(http.StatusOK, buildMyAccessViews(db, clientDataList))
+ }
+}
+
+// GetMyAccessClientQRCode returns the QR code (as a data-URI, same shape
+// produced elsewhere in the app) for a single client, provided the
+// requesting user is authorized to see it per the ServerIDs/ClientIDs union
+// rule - independent of whether the route-level RequireServerAccess
+// middleware would have allowed it, since a ClientIDs-only grant may cover a
+// client on a server the user otherwise has no access to at all.
+func GetMyAccessClientQRCode(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ clientID := c.Param("cid")
+ if _, err := xid.FromString(clientID); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
+ }
+
+ admin := isAdmin(c)
+ user, err := db.GetUserByName(currentUser(c))
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"})
+ }
+
+ qrCodeSettings := model.QRCodeSettings{
+ Enabled: true,
+ IncludeDNS: true,
+ IncludeMTU: true,
+ }
+
+ clientData, err := db.GetClientByID(clientID, qrCodeSettings)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
+ }
+
+ if !clientAuthorizedForUser(user, admin, clientData) {
+ return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"})
+ }
+
+ return c.JSON(http.StatusOK, util.FillClientSubnetRange(clientData))
+ }
+}
+
+// GetMyAccessClientDownload streams the client's WireGuard config file,
+// same content as the existing DownloadClient handler, but authorized via
+// the ServerIDs/ClientIDs union rule instead of a server-scoped route
+// parameter.
+func GetMyAccessClientDownload(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ clientID := c.Param("cid")
+ if _, err := xid.FromString(clientID); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
+ }
+
+ admin := isAdmin(c)
+ user, err := db.GetUserByName(currentUser(c))
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot load current user"})
+ }
+
+ clientData, err := db.GetClientByID(clientID, model.QRCodeSettings{Enabled: false})
+ if err != nil {
+ log.Errorf("Cannot generate client id %s config file for downloading: %v", clientID, err)
+ return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Client not found"})
+ }
+
+ if !clientAuthorizedForUser(user, admin, clientData) {
+ return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"})
+ }
+
+ clientServerID := clientData.Client.ServerID
+ if clientServerID == "" {
+ clientServerID = util.DefaultServerID
+ }
+ server, err := db.GetServerByID(clientServerID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ globalSettings, err := buildEffectiveSettings(db, clientServerID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ config := util.BuildClientConfig(*clientData.Client, server, globalSettings)
+
+ reader := strings.NewReader(config)
+ c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
+ return c.Stream(http.StatusOK, "text/conf", reader)
+ }
+}
diff --git a/main.go b/main.go
index a92e0b6..631669c 100644
--- a/main.go
+++ b/main.go
@@ -222,8 +222,16 @@ func main() {
if !util.DisableLogin {
app.GET(util.BasePath+"/login", handler.LoginPage())
app.POST(util.BasePath+"/login", handler.Login(db), handler.ContentTypeJson)
+ app.POST(util.BasePath+"/login/totp", handler.VerifyTOTPLogin(db), handler.ContentTypeJson)
app.GET(util.BasePath+"/logout", handler.Logout(), handler.ValidSession)
+ app.GET(util.BasePath+"/my-access", handler.MyAccessPage(db), handler.ValidSession, handler.RefreshSession)
+ app.GET(util.BasePath+"/my-access/api/clients", handler.GetMyAccessClients(db), handler.ValidSession)
+ app.GET(util.BasePath+"/my-access/client/:cid/qrcode", handler.GetMyAccessClientQRCode(db), handler.ValidSession)
+ app.GET(util.BasePath+"/my-access/client/:cid/download", handler.GetMyAccessClientDownload(db), handler.ValidSession)
app.GET(util.BasePath+"/profile", handler.LoadProfile(), handler.ValidSession, handler.RefreshSession)
+ app.GET(util.BasePath+"/profile/totp/enroll", handler.EnrollTOTP(db), handler.ValidSession)
+ app.POST(util.BasePath+"/profile/totp/confirm", handler.ConfirmTOTP(db), handler.ValidSession, handler.ContentTypeJson)
+ app.POST(util.BasePath+"/profile/totp/disable", handler.DisableTOTP(db), handler.ValidSession, handler.ContentTypeJson)
app.GET(util.BasePath+"/users-settings", handler.UsersSettings(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/update-user", handler.UpdateUser(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
diff --git a/model/user.go b/model/user.go
index 27ec11e..eff4b5c 100644
--- a/model/user.go
+++ b/model/user.go
@@ -11,4 +11,14 @@ type User struct {
// Empty/nil means no server access at all (secure by default).
// Admins always have access to every server regardless of this field.
ServerIDs []string `json:"server_ids,omitempty"`
+ // ClientIDs grants a non-admin user visibility into these individual
+ // clients regardless of which server they belong to, in addition to
+ // whatever ServerIDs already grants full-server visibility into.
+ ClientIDs []string `json:"client_ids,omitempty"`
+ // TOTPSecret is the base32-encoded shared secret for this user's TOTP
+ // two-factor login (RFC 6238). Empty means 2FA is not enrolled.
+ TOTPSecret string `json:"totp_secret,omitempty"`
+ // TOTPEnabled gates whether TOTP is actually required at login. A user
+ // can have a secret provisioned but not yet confirm/enable it.
+ TOTPEnabled bool `json:"totp_enabled,omitempty"`
}
diff --git a/templates/base.html b/templates/base.html
index fec9b29..0cb0e0b 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -126,6 +126,14 @@
+
+
+
+
+ My Access
+
+
+
{{if .baseData.Admin}}
diff --git a/templates/login.html b/templates/login.html
index c0a96b9..9172424 100644
--- a/templates/login.html
+++ b/templates/login.html
@@ -64,6 +64,22 @@
+
@@ -93,11 +109,16 @@
+{{end}}
diff --git a/templates/profile.html b/templates/profile.html
index fa80157..58e6da7 100644
--- a/templates/profile.html
+++ b/templates/profile.html
@@ -47,6 +47,43 @@ Profile
+
+
+
+
+
+
+
Two-factor authentication is not enabled.
+
+
+
+
Scan this QR code with your authenticator app, or enter the secret manually:
+
+
![TOTP QR code]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enabled Two-factor authentication is enabled on your account.
+
+
+
+
+
+
+
@@ -132,5 +169,97 @@ Profile
}
});
});
+
+ function refreshTotpUiState(enabled) {
+ if (enabled) {
+ $("#totp-disabled-panel").hide();
+ $("#totp-enroll-panel").hide();
+ $("#totp-enabled-panel").show();
+ } else {
+ $("#totp-enabled-panel").hide();
+ $("#totp-disabled-panel").show();
+ }
+ }
+
+ $(document).ready(function () {
+ $.ajax({
+ cache: false,
+ method: 'GET',
+ url: '{{.basePath}}/api/user/{{.baseData.CurrentUser}}',
+ dataType: 'json',
+ contentType: "application/json",
+ success: function (resp) {
+ refreshTotpUiState(!!resp.totp_enabled);
+ },
+ error: function (jqXHR, exception) {
+ const responseJson = jQuery.parseJSON(jqXHR.responseText);
+ toastr.error(responseJson['message']);
+ }
+ });
+
+ $("#btn_totp_setup").click(function () {
+ $.ajax({
+ cache: false,
+ method: 'GET',
+ url: '{{.basePath}}/profile/totp/enroll',
+ dataType: 'json',
+ contentType: "application/json",
+ success: function (resp) {
+ $("#totp-qrcode").attr("src", resp.qrcode);
+ $("#totp-secret").val(resp.secret);
+ $("#totp-enroll-panel").show();
+ },
+ error: function (jqXHR, exception) {
+ const responseJson = jQuery.parseJSON(jqXHR.responseText);
+ toastr.error(responseJson['message']);
+ }
+ });
+ });
+
+ $("#btn_totp_confirm").click(function () {
+ const code = $("#totp-confirm-code").val();
+ $.ajax({
+ cache: false,
+ method: 'POST',
+ url: '{{.basePath}}/profile/totp/confirm',
+ dataType: 'json',
+ contentType: "application/json",
+ data: JSON.stringify({"code": code}),
+ success: function (resp) {
+ toastr.success("Two-factor authentication enabled");
+ refreshTotpUiState(true);
+ },
+ error: function (jqXHR, exception) {
+ const responseJson = jQuery.parseJSON(jqXHR.responseText);
+ toastr.error(responseJson['message']);
+ }
+ });
+ });
+
+ $("#btn_totp_disable").click(function () {
+ if (!confirm("Disable two-factor authentication?")) {
+ return;
+ }
+ $.ajax({
+ cache: false,
+ method: 'POST',
+ url: '{{.basePath}}/profile/totp/disable',
+ dataType: 'json',
+ contentType: "application/json",
+ data: JSON.stringify({}),
+ success: function (resp) {
+ toastr.success("Two-factor authentication disabled");
+ $("#totp-secret").val("");
+ $("#totp-confirm-code").val("");
+ $("#totp-qrcode").attr("src", "");
+ refreshTotpUiState(false);
+ },
+ error: function (jqXHR, exception) {
+ const responseJson = jQuery.parseJSON(jqXHR.responseText);
+ toastr.error(responseJson['message']);
+ }
+ });
+ });
+ });
{{ end }}
diff --git a/templates/users_settings.html b/templates/users_settings.html
index 6f71e17..cdf47cb 100644
--- a/templates/users_settings.html
+++ b/templates/users_settings.html
@@ -59,6 +59,12 @@ Users Settings
Servers this user (if non-admin) may access. Admins always have access to all servers.
+
+
+
+ Grants a non-admin user visibility into these specific clients, even without full server access.
+