diff --git a/handler/routes.go b/handler/routes.go index 76b35e4..c03ab7f 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -419,6 +419,26 @@ func ListServers(db store.IStore) echo.HandlerFunc { false, fmt.Sprintf("Cannot get server list: %v", err), }) } + + // non-admins only see servers they have been explicitly granted + if !util.DisableLogin && !isAdmin(c) { + user, err := db.GetUserByName(currentUser(c)) + if err != nil { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) + } + allowed := make(map[string]bool, len(user.ServerIDs)) + for _, id := range user.ServerIDs { + allowed[id] = true + } + var filtered []model.Server + for _, s := range servers { + if allowed[s.ID] { + filtered = append(filtered, s) + } + } + servers = filtered + } + return c.JSON(http.StatusOK, servers) } } diff --git a/handler/session.go b/handler/session.go index b660d9c..7c25e45 100644 --- a/handler/session.go +++ b/handler/session.go @@ -8,6 +8,7 @@ import ( "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" + "github.com/ngoduykhanh/wireguard-ui/store" "github.com/ngoduykhanh/wireguard-ui/util" ) @@ -43,6 +44,34 @@ func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc { } } +// RequireServerAccess must only be used after ValidSession middleware. +// It restricts /servers/:id/... routes to admins (always allowed) and to +// non-admin users whose User.ServerIDs contains the requested server ID. +// A user with an empty ServerIDs list has no server access at all - the +// migration backfills existing users so nobody is locked out by this +// change on upgrade. +func RequireServerAccess(db store.IStore) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if util.DisableLogin || isAdmin(c) { + return next(c) + } + + serverID := c.Param("id") + user, err := db.GetUserByName(currentUser(c)) + if err != nil { + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Access denied"}) + } + for _, id := range user.ServerIDs { + if id == serverID { + return next(c) + } + } + return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "You do not have access to this server"}) + } + } +} + func isValidSession(c echo.Context) bool { if util.DisableLogin { return true diff --git a/main.go b/main.go index c776b95..2bf23c7 100644 --- a/main.go +++ b/main.go @@ -252,7 +252,7 @@ func main() { app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession) app.GET(util.BasePath+"/servers", handler.ListServers(db), handler.ValidSession) - app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession) + app.GET(util.BasePath+"/servers/:id/clients", handler.GetServerClients(db), handler.ValidSession, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession) diff --git a/model/user.go b/model/user.go index 71f4d13..27ec11e 100644 --- a/model/user.go +++ b/model/user.go @@ -7,4 +7,8 @@ type User struct { // PasswordHash takes precedence over Password. PasswordHash string `json:"password_hash"` Admin bool `json:"admin"` + // ServerIDs restricts a non-admin user to only these servers. + // Empty/nil means no server access at all (secure by default). + // Admins always have access to every server regardless of this field. + ServerIDs []string `json:"server_ids,omitempty"` } diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go index 12b7330..e755e82 100644 --- a/store/jsondb/jsondb.go +++ b/store/jsondb/jsondb.go @@ -303,6 +303,27 @@ func (o *JsonDB) migrateLegacyServer() error { } } + // backfill ServerIDs on every existing user so nobody is locked out of + // the migrated server by the new per-server access control (users + // created after migration default to no access, per model.User). + userRecords, err := o.conn.ReadAll("users") + if err != nil && err != scribble.ErrMissingCollection { + return fmt.Errorf("migration: cannot read users: %v", err) + } + for _, rec := range userRecords { + var user model.User + if err := json.Unmarshal(rec, &user); err != nil { + return fmt.Errorf("migration: cannot decode user json: %v", err) + } + if len(user.ServerIDs) > 0 { + continue + } + user.ServerIDs = []string{serverID} + if err := o.conn.Write("users", user.Username, user); err != nil { + return fmt.Errorf("migration: cannot backfill server_ids on user %s: %v", user.Username, err) + } + } + // NOTE: the legacy "server" directory is intentionally left in place // (not renamed/removed) at this stage. Existing store methods (GetServer, // GetGlobalSettings, SaveServerInterface, ...) still read/write it