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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user