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:
sysops
2026-07-25 00:42:05 +02:00
co-authored by Claude Sonnet 5
parent c29edfdcc3
commit 34bc8f76f9
12 changed files with 1213 additions and 36 deletions
+263 -34
View File
@@ -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()})
}
+263
View File
@@ -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)
}
}