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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c29edfdcc3
commit
34bc8f76f9
@@ -2439,3 +2439,83 @@ Keine Commits in dieser Session.
|
|||||||
- opnsense/parse.go | 78 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------
|
- 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 +++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+148
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+235
-6
@@ -24,6 +24,7 @@ import (
|
|||||||
"golang.zx2c4.com/wireguard/wgctrl"
|
"golang.zx2c4.com/wireguard/wgctrl"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||||
|
|
||||||
|
"github.com/ngoduykhanh/wireguard-ui/auth"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/backup"
|
"github.com/ngoduykhanh/wireguard-ui/backup"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/firewall"
|
"github.com/ngoduykhanh/wireguard-ui/firewall"
|
||||||
@@ -135,6 +136,44 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if userCorrect && passwordCorrect {
|
if userCorrect && passwordCorrect {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)", username, c.Request().RemoteAddr)
|
||||||
|
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warnf("Invalid credentials user %s (%s)", username, c.Request().RemoteAddr)
|
||||||
|
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
ageMax := 0
|
||||||
if rememberMe {
|
if rememberMe {
|
||||||
ageMax = 86400 * 7
|
ageMax = 86400 * 7
|
||||||
@@ -160,7 +199,9 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||||||
sess.Values["max_age"] = ageMax
|
sess.Values["max_age"] = ageMax
|
||||||
sess.Values["created_at"] = now
|
sess.Values["created_at"] = now
|
||||||
sess.Values["updated_at"] = now
|
sess.Values["updated_at"] = now
|
||||||
sess.Save(c.Request(), c.Response())
|
if err := sess.Save(c.Request(), c.Response()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// set session_token in cookie
|
// set session_token in cookie
|
||||||
cookie := new(http.Cookie)
|
cookie := new(http.Cookie)
|
||||||
@@ -172,12 +213,51 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||||||
cookie.SameSite = http.SameSiteLaxMode
|
cookie.SameSite = http.SameSiteLaxMode
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
log.Infof("Logged in successfully user %s (%s)", username, c.Request().RemoteAddr)
|
return nil
|
||||||
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Logged in successfully"})
|
}
|
||||||
|
|
||||||
|
// 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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Warnf("Invalid credentials user %s (%s)", username, c.Request().RemoteAddr)
|
rememberMe, _ := sess.Values["pending_remember_me"].(bool)
|
||||||
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
|
|
||||||
|
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"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
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"})
|
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)
|
return c.JSON(http.StatusOK, userData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +398,7 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
|
|||||||
user.Admin = admin
|
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 isAdmin(c) {
|
||||||
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
if rawIDs, ok := data["server_ids"].([]interface{}); ok {
|
||||||
serverIDs := make([]string, 0, len(rawIDs))
|
serverIDs := make([]string, 0, len(rawIDs))
|
||||||
@@ -320,6 +409,16 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
user.ServerIDs = serverIDs
|
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 {
|
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
|
// CreateUser to create new user
|
||||||
func CreateUser(db store.IStore) echo.HandlerFunc {
|
func CreateUser(db store.IStore) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
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 {
|
if err := db.SaveUser(user); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -222,8 +222,16 @@ func main() {
|
|||||||
if !util.DisableLogin {
|
if !util.DisableLogin {
|
||||||
app.GET(util.BasePath+"/login", handler.LoginPage())
|
app.GET(util.BasePath+"/login", handler.LoginPage())
|
||||||
app.POST(util.BasePath+"/login", handler.Login(db), handler.ContentTypeJson)
|
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+"/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", 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.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+"/update-user", handler.UpdateUser(db), handler.ValidSession, handler.ContentTypeJson)
|
||||||
app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
||||||
|
|||||||
@@ -11,4 +11,14 @@ type User struct {
|
|||||||
// Empty/nil means no server access at all (secure by default).
|
// Empty/nil means no server access at all (secure by default).
|
||||||
// Admins always have access to every server regardless of this field.
|
// Admins always have access to every server regardless of this field.
|
||||||
ServerIDs []string `json:"server_ids,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,14 @@
|
|||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="{{.basePath}}/my-access" class="nav-link {{if eq .baseData.Active "my-access" }}active{{end}}">
|
||||||
|
<i class="nav-icon fas fa-id-badge"></i>
|
||||||
|
<p>
|
||||||
|
My Access
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
{{if .baseData.Admin}}
|
{{if .baseData.Admin}}
|
||||||
<li class="nav-header">SETTINGS</li>
|
<li class="nav-header">SETTINGS</li>
|
||||||
|
|||||||
+52
-1
@@ -64,6 +64,22 @@
|
|||||||
<!-- /.col -->
|
<!-- /.col -->
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<form id="totp-form" action="" method="post" style="display:none;">
|
||||||
|
<p class="login-box-msg">Enter the 6-digit code from your authenticator app</p>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input id="totp_code" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="form-control" placeholder="123456">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<div class="input-group-text">
|
||||||
|
<span class="fas fa-shield-alt"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<button id="btn_totp" type="submit" class="btn btn-primary btn-block">Verify</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
<div class="text-center mb-3">
|
<div class="text-center mb-3">
|
||||||
<p id="message"></p>
|
<p id="message"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,11 +109,16 @@
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$('form').on('submit', function(e) {
|
$('#username, #password').closest('form').on('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$("#btn_login").trigger('click');
|
$("#btn_login").trigger('click');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#totp-form').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$("#btn_totp").trigger('click');
|
||||||
|
});
|
||||||
|
|
||||||
$("#btn_login").click(function () {
|
$("#btn_login").click(function () {
|
||||||
const username = $("#username").val();
|
const username = $("#username").val();
|
||||||
const password = $("#password").val();
|
const password = $("#password").val();
|
||||||
@@ -114,6 +135,36 @@
|
|||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
|
success: function(data) {
|
||||||
|
if (data['totp_required']) {
|
||||||
|
document.getElementById("message").innerHTML = "";
|
||||||
|
$('#username').closest('form').hide();
|
||||||
|
$('#totp-form').show();
|
||||||
|
$('#totp_code').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
|
||||||
|
// redirect after logging in successfully
|
||||||
|
redirectNext();
|
||||||
|
},
|
||||||
|
error: function(jqXHR, exception) {
|
||||||
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
|
document.getElementById("message").innerHTML = `<p style="color:#ff0000">${responseJson['message']}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#btn_totp").click(function () {
|
||||||
|
const code = $("#totp_code").val();
|
||||||
|
const data = {"code": code}
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'POST',
|
||||||
|
url: '{{.basePath}}/login/totp',
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: "application/json",
|
||||||
|
data: JSON.stringify(data),
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
|
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
|
||||||
// redirect after logging in successfully
|
// redirect after logging in successfully
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{{define "title"}}
|
||||||
|
My Access
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "top_css"}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "username"}}
|
||||||
|
{{ .username }}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "page_title"}}
|
||||||
|
My Access
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "page_content"}}
|
||||||
|
<section class="content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-2 flex-wrap">
|
||||||
|
<h5 class="mb-2">My Access</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if not .clients}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No VPN access has been assigned to your account yet — contact an administrator.
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="row" id="my-access-list">
|
||||||
|
{{range .clients}}
|
||||||
|
<div class="col-12 col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">{{.Name}}</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
{{if .Enabled}}
|
||||||
|
<span class="badge badge-success">Enabled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge badge-secondary">Disabled</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-1"><strong>Email:</strong> {{if .Email}}{{.Email}}{{else}}—{{end}}</p>
|
||||||
|
<p class="mb-1"><strong>Server:</strong> {{.ServerName}} ({{.Interface}})</p>
|
||||||
|
<p class="mb-1"><strong>Allocated IP:</strong> {{ StringsJoin .AllocatedIPs ", " }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm btn-show-qr" data-clientid="{{.ID}}" data-clientname="{{.Name}}" data-toggle="modal" data-target="#modal_qr_client">
|
||||||
|
<i class="fas fa-qrcode"></i> Show QR
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{{$.basePath}}/my-access/client/{{.ID}}/download">
|
||||||
|
<i class="fas fa-download"></i> Download config
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="modal fade" id="modal_qr_client">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">QR Code</h4>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-between">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-content -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-dialog -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal -->
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "bottom_js"}}
|
||||||
|
<script>
|
||||||
|
$("#modal_qr_client").on('show.bs.modal', function (event) {
|
||||||
|
const button = $(event.relatedTarget);
|
||||||
|
const clientId = button.data('clientid');
|
||||||
|
const clientName = button.data('clientname');
|
||||||
|
const modal = $(this);
|
||||||
|
const qrImg = $("#qr_code");
|
||||||
|
|
||||||
|
modal.find(".modal-title").text("Scan QR Code for " + clientName);
|
||||||
|
qrImg.hide();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'GET',
|
||||||
|
url: '{{.basePath}}/my-access/client/' + clientId + '/qrcode',
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function (resp) {
|
||||||
|
if (resp.QRCode) {
|
||||||
|
qrImg.attr('src', resp.QRCode).show();
|
||||||
|
} else {
|
||||||
|
toastr.error('No QR code available for this client');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function (jqXHR) {
|
||||||
|
try {
|
||||||
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
|
toastr.error(responseJson['message']);
|
||||||
|
} catch (e) {
|
||||||
|
toastr.error('Failed to load QR code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -47,6 +47,43 @@ Profile
|
|||||||
</div>
|
</div>
|
||||||
<!-- /.card -->
|
<!-- /.card -->
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card card-primary">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Two-Factor Authentication (TOTP)</h3>
|
||||||
|
</div>
|
||||||
|
<!-- /.card-header -->
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="totp-disabled-panel">
|
||||||
|
<p id="totp-status-text">Two-factor authentication is not enabled.</p>
|
||||||
|
<button type="button" class="btn btn-primary" id="btn_totp_setup">Set up 2FA</button>
|
||||||
|
|
||||||
|
<div id="totp-enroll-panel" style="display:none; margin-top: 15px;">
|
||||||
|
<p>Scan this QR code with your authenticator app, or enter the secret manually:</p>
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<img id="totp-qrcode" src="" alt="TOTP QR code" style="max-width:200px;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-secret" class="control-label">Secret</label>
|
||||||
|
<input type="text" class="form-control" id="totp-secret" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp-confirm-code" class="control-label">Enter the 6-digit code to confirm</label>
|
||||||
|
<input type="text" inputmode="numeric" maxlength="6" class="form-control" id="totp-confirm-code" placeholder="123456">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" id="btn_totp_confirm">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="totp-enabled-panel" style="display:none;">
|
||||||
|
<p><span class="badge badge-success">Enabled</span> Two-factor authentication is enabled on your account.</p>
|
||||||
|
<button type="button" class="btn btn-danger" id="btn_totp_disable">Disable 2FA</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.card-body -->
|
||||||
|
</div>
|
||||||
|
<!-- /.card -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- /.row -->
|
<!-- /.row -->
|
||||||
</div>
|
</div>
|
||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ Users Settings
|
|||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Servers this user (if non-admin) may access. Admins always have access to all servers.</small>
|
<small class="form-text text-muted">Servers this user (if non-admin) may access. Admins always have access to all servers.</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_client_ids" class="control-label">Individual Client Access</label>
|
||||||
|
<select multiple class="form-control" id="_client_ids" name="_client_ids">
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Grants a non-admin user visibility into these specific clients, even without full server access.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer justify-content-between">
|
<div class="modal-footer justify-content-between">
|
||||||
@@ -179,7 +185,9 @@ Users Settings
|
|||||||
success: function (servers) {
|
success: function (servers) {
|
||||||
const select = modal.find("#_server_ids");
|
const select = modal.find("#_server_ids");
|
||||||
select.empty();
|
select.empty();
|
||||||
|
const serverNameById = {};
|
||||||
$.each(servers, function (index, srv) {
|
$.each(servers, function (index, srv) {
|
||||||
|
serverNameById[srv.id] = srv.name;
|
||||||
select.append($('<option>').val(srv.id).text(srv.name + " (" + srv.id + ")"));
|
select.append($('<option>').val(srv.id).text(srv.name + " (" + srv.id + ")"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +196,35 @@ Users Settings
|
|||||||
if (user_name !== "") {
|
if (user_name !== "") {
|
||||||
select.val(select.data('preselect') || []);
|
select.val(select.data('preselect') || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// populate the individual client access select, labeling each option
|
||||||
|
// with both the client's name/email and the server it belongs to so
|
||||||
|
// admins aren't picking blind between same-named clients on different servers
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'GET',
|
||||||
|
url: '{{.basePath}}/api/clients',
|
||||||
|
dataType: 'json',
|
||||||
|
contentType: "application/json",
|
||||||
|
success: function (clientDataList) {
|
||||||
|
const clientSelect = modal.find("#_client_ids");
|
||||||
|
clientSelect.empty();
|
||||||
|
$.each(clientDataList, function (index, clientData) {
|
||||||
|
const client = clientData.Client;
|
||||||
|
const serverName = serverNameById[client.server_id] || client.server_id || "unknown server";
|
||||||
|
const label = (client.name || client.email || client.id) + " — " + serverName;
|
||||||
|
clientSelect.append($('<option>').val(client.id).text(label));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user_name !== "") {
|
||||||
|
clientSelect.val(clientSelect.data('preselect') || []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function (jqXHR, exception) {
|
||||||
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
|
toastr.error(responseJson['message']);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
@@ -216,6 +253,10 @@ Users Settings
|
|||||||
// once its options have been populated (see the servers ajax above)
|
// once its options have been populated (see the servers ajax above)
|
||||||
modal.find("#_server_ids").data('preselect', user.server_ids || []);
|
modal.find("#_server_ids").data('preselect', user.server_ids || []);
|
||||||
modal.find("#_server_ids").val(user.server_ids || []);
|
modal.find("#_server_ids").val(user.server_ids || []);
|
||||||
|
// remember the granted client ids so the select can pre-select them
|
||||||
|
// once its options have been populated (see the clients ajax above)
|
||||||
|
modal.find("#_client_ids").data('preselect', user.client_ids || []);
|
||||||
|
modal.find("#_client_ids").val(user.client_ids || []);
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
@@ -230,6 +271,7 @@ Users Settings
|
|||||||
modal.find("#_user_password").prop("placeholder", "")
|
modal.find("#_user_password").prop("placeholder", "")
|
||||||
modal.find("#_admin").prop("checked", false);
|
modal.find("#_admin").prop("checked", false);
|
||||||
modal.find("#_server_ids").data('preselect', []);
|
modal.find("#_server_ids").data('preselect', []);
|
||||||
|
modal.find("#_client_ids").data('preselect', []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -243,12 +285,14 @@ Users Settings
|
|||||||
admin = true;
|
admin = true;
|
||||||
}
|
}
|
||||||
const server_ids = $("#_server_ids").val() || [];
|
const server_ids = $("#_server_ids").val() || [];
|
||||||
|
const client_ids = $("#_client_ids").val() || [];
|
||||||
const data = {
|
const data = {
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"previous_username": previous_username,
|
"previous_username": previous_username,
|
||||||
"admin": admin,
|
"admin": admin,
|
||||||
"server_ids": server_ids
|
"server_ids": server_ids,
|
||||||
|
"client_ids": client_ids
|
||||||
};
|
};
|
||||||
|
|
||||||
if (previous_username !== "") {
|
if (previous_username !== "") {
|
||||||
|
|||||||
Reference in New Issue
Block a user