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 @@

+ {{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 (TOTP)

+
+ +
+
+

Two-factor authentication is not enabled.

+ + + +
+ + +
+ +
+ +
@@ -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. +