From 66aa2fa25bf84f12bf9d23a680e31b43627413c5 Mon Sep 17 00:00:00 2001 From: Chris Baumgartner Date: Tue, 14 Jul 2026 09:27:43 -0500 Subject: [PATCH 01/28] Initial changes to support admin and support groups for OIDC authenticated users. Support can only ban and unban IPs. Admin can do all UI functions. --- docker-compose-allinone.example.yml | 6 ++ docker-compose.example.yml | 6 ++ docs/api.md | 1 + docs/configuration.md | 8 ++ internal/auth/authorization_test.go | 53 +++++++++++ internal/auth/oidc.go | 132 ++++++++++++++++++++++++++-- internal/auth/session.go | 24 ++--- internal/config/settings.go | 52 ++++++++--- pkg/web/auth.go | 2 + pkg/web/authorization.go | 57 ++++++++++++ pkg/web/handlers.go | 65 +++++++++----- pkg/web/routes.go | 78 ++++++++-------- pkg/web/static/js/auth.js | 24 +++++ pkg/web/static/js/core.js | 3 + pkg/web/templates/index.html | 12 +-- 15 files changed, 428 insertions(+), 95 deletions(-) create mode 100644 internal/auth/authorization_test.go create mode 100644 pkg/web/authorization.go diff --git a/docker-compose-allinone.example.yml b/docker-compose-allinone.example.yml index f28d34d..8e94fb2 100644 --- a/docker-compose-allinone.example.yml +++ b/docker-compose-allinone.example.yml @@ -113,6 +113,12 @@ services: # Optional: Username claim (default: preferred_username) # The claim to use as the username (e.g., email, preferred_username, sub) # - OIDC_USERNAME_CLAIM=preferred_username + # Optional: OIDC role-based access control + # If no admin/support roles are configured, all authenticated users have full access. + # Dot paths are supported for the role claim, e.g. realm_access.roles for Keycloak. + # - OIDC_ROLE_CLAIM=groups + # - OIDC_ADMIN_ROLES=fail2ban-admins + # - OIDC_SUPPORT_ROLES=fail2ban-support # Optional: Provider logout URL # If not set, the logout URL will be auto-constructed based on the provider: # Keycloak: {issuer}/protocol/openid-connect/logout diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 3dcac0b..34d56c0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -94,6 +94,12 @@ services: # Optional: Username claim (default: preferred_username) # The claim to use as the username (e.g., email, preferred_username, sub) # - OIDC_USERNAME_CLAIM=preferred_username + # Optional: OIDC role-based access control + # If no admin/support roles are configured, all authenticated users have full access. + # Dot paths are supported for the role claim, e.g. realm_access.roles for Keycloak. + # - OIDC_ROLE_CLAIM=groups + # - OIDC_ADMIN_ROLES=fail2ban-admins + # - OIDC_SUPPORT_ROLES=fail2ban-support # Optional: Provider logout URL # If not set, the logout URL will be auto-constructed based on the provider: # Keycloak: {issuer}/protocol/openid-connect/logout diff --git a/docs/api.md b/docs/api.md index a41fe8d..b507262 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,6 +5,7 @@ This is a practical endpoint index for operators. The web frontend uses these en ## Authentication * When OIDC is enabled, all `/api/*` endpoints, including the WebSocket, require an authenticated session - except the callback endpoints. +* Optional OIDC role-based access control can further restrict authenticated users. `admin` users can access everything; `support` users can view operational dashboard/event data and manually ban/unban IPs. * The callback endpoints (`/api/ban`, `/api/unban`) are authenticated through the `X-Callback-Secret` header. ## Input validation diff --git a/docs/configuration.md b/docs/configuration.md index 9f34985..aeccf11 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -133,6 +133,14 @@ Common optional variables: | `OIDC_SKIP_VERIFY` | `false` | Skips TLS verification toward the provider. Development only. | | `OIDC_SKIP_LOGINPAGE` | `false` | Skips the UI login page and redirects to the provider directly | +OIDC role-based access control is optional. When no role variables are set, every authenticated OIDC user keeps the previous full-access behavior. + +| Variable | Default | Description | +|----------|---------|-------------| +| `OIDC_ROLE_CLAIM` | `groups` | Claim containing roles/groups. Dot paths are supported, for example `realm_access.roles` for Keycloak. | +| `OIDC_ADMIN_ROLES` | empty | Comma-separated OIDC role/group names that grant full admin access. | +| `OIDC_SUPPORT_ROLES` | empty | Comma-separated OIDC role/group names that grant support access: dashboard/event reads plus manual ban/unban. | + Provider notes: * **Keycloak**: allow the redirect URI `{BASE_PATH}/auth/callback` (or `/auth/callback` at root) and the post-logout redirect `{BASE_PATH}/auth/login`. diff --git a/internal/auth/authorization_test.go b/internal/auth/authorization_test.go new file mode 100644 index 0000000..478b94e --- /dev/null +++ b/internal/auth/authorization_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "testing" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +func TestAccessLevelForRoles(t *testing.T) { + cfg := &config.OIDCConfig{ + AuthorizationEnabled: true, + AdminRoles: []string{"fail2ban-admins"}, + SupportRoles: []string{"fail2ban-support"}, + } + + if got := accessLevelForRoles(cfg, []string{"fail2ban-admins"}); got != AccessLevelAdmin { + t.Fatalf("admin role access level = %q, want %q", got, AccessLevelAdmin) + } + if got := accessLevelForRoles(cfg, []string{"fail2ban-support"}); got != AccessLevelSupport { + t.Fatalf("support role access level = %q, want %q", got, AccessLevelSupport) + } + if got := accessLevelForRoles(cfg, []string{"other"}); got != "" { + t.Fatalf("unknown role access level = %q, want empty", got) + } +} + +func TestSessionHasPermission(t *testing.T) { + admin := &Session{AccessLevel: AccessLevelAdmin} + if !SessionHasPermission(admin, "admin") || !SessionHasPermission(admin, "ban") { + t.Fatal("admin should have all permissions") + } + + support := &Session{AccessLevel: AccessLevelSupport} + if !SessionHasPermission(support, "read") || !SessionHasPermission(support, "ban") { + t.Fatal("support should have read and ban permissions") + } + if SessionHasPermission(support, "admin") { + t.Fatal("support should not have admin permission") + } +} + +func TestClaimByPathAndStringSliceFromClaim(t *testing.T) { + claims := map[string]interface{}{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"fail2ban-admins", "offline_access"}, + }, + } + + roles := stringSliceFromClaim(claimByPath(claims, "realm_access.roles")) + if len(roles) != 2 || roles[0] != "fail2ban-admins" || roles[1] != "offline_access" { + t.Fatalf("roles = %#v, want nested role slice", roles) + } +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 1297d2f..5144612 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -41,16 +41,81 @@ type OIDCClient struct { } type UserInfo struct { - ID string - Email string - Name string - Username string + ID string + Email string + Name string + Username string + Roles []string + AccessLevel string } var ( oidcClient *OIDCClient ) +const ( + AccessLevelAdmin = "admin" + AccessLevelSupport = "support" +) + +func AuthorizationEnabled() bool { + cfg := GetConfig() + return cfg != nil && cfg.AuthorizationEnabled +} + +func roleSet(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + set[value] = struct{}{} + } + } + return set +} + +func hasAnyRole(userRoles []string, allowedRoles []string) bool { + allowed := roleSet(allowedRoles) + if len(allowed) == 0 { + return false + } + for _, role := range userRoles { + if _, ok := allowed[role]; ok { + return true + } + } + return false +} + +func accessLevelForRoles(cfg *config.OIDCConfig, roles []string) string { + if cfg == nil || !cfg.AuthorizationEnabled { + return AccessLevelAdmin + } + if hasAnyRole(roles, cfg.AdminRoles) { + return AccessLevelAdmin + } + if hasAnyRole(roles, cfg.SupportRoles) { + return AccessLevelSupport + } + return "" +} + +func SessionHasPermission(session *Session, permission string) bool { + if session == nil { + return false + } + if session.AccessLevel == AccessLevelAdmin { + return true + } + if session.AccessLevel == AccessLevelSupport { + switch permission { + case "read", "ban": + return true + } + } + return false +} + // ========================================================================= // Initialization // ========================================================================= @@ -155,6 +220,51 @@ func (c *OIDCClient) GetAuthURL(state string) string { return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline) } +func claimByPath(claims map[string]interface{}, path string) interface{} { + if path == "" { + return nil + } + var current interface{} = claims + for _, part := range strings.Split(path, ".") { + m, ok := current.(map[string]interface{}) + if !ok { + return nil + } + current = m[part] + } + return current +} + +func stringSliceFromClaim(value interface{}) []string { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out + case []string: + return v + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + default: + return nil + } +} + // Exchanges the authorization code for tokens. func (c *OIDCClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) { if c.OAuth2Config == nil { @@ -197,10 +307,18 @@ func (c *OIDCClient) VerifyToken(ctx context.Context, token *oauth2.Token) (*Use return nil, fmt.Errorf("failed to extract claims: %w", err) } + allClaims := map[string]interface{}{} + if err := idToken.Claims(&allClaims); err != nil { + return nil, fmt.Errorf("failed to extract role claims: %w", err) + } + roles := stringSliceFromClaim(claimByPath(allClaims, c.Config.RoleClaim)) + userInfo := &UserInfo{ - ID: claims.Subject, - Email: claims.Email, - Name: claims.Name, + ID: claims.Subject, + Email: claims.Email, + Name: claims.Name, + Roles: roles, + AccessLevel: accessLevelForRoles(c.Config, roles), } switch c.Config.UsernameClaim { diff --git a/internal/auth/session.go b/internal/auth/session.go index 88799e3..bd082d0 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -34,11 +34,13 @@ import ( // ========================================================================= type Session struct { - UserID string `json:"userID"` - Email string `json:"email"` - Name string `json:"name"` - Username string `json:"username"` - ExpiresAt time.Time `json:"expiresAt"` + UserID string `json:"userID"` + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"username"` + Roles []string `json:"roles,omitempty"` + AccessLevel string `json:"accessLevel,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` } const ( @@ -96,11 +98,13 @@ func InitializeSessionSecret(secret string) error { // Creates a session cookie with the user info. func CreateSession(w http.ResponseWriter, r *http.Request, userInfo *UserInfo, maxAge int) error { session := &Session{ - UserID: userInfo.ID, - Email: userInfo.Email, - Name: userInfo.Name, - Username: userInfo.Username, - ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second), + UserID: userInfo.ID, + Email: userInfo.Email, + Name: userInfo.Name, + Username: userInfo.Username, + Roles: userInfo.Roles, + AccessLevel: userInfo.AccessLevel, + ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second), } sessionData, err := json.Marshal(session) diff --git a/internal/config/settings.go b/internal/config/settings.go index eda5538..968f211 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -149,19 +149,23 @@ type ThreatIntelSettings struct { } type OIDCConfig struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - IssuerURL string `json:"issuerURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURL string `json:"redirectURL"` - Scopes []string `json:"scopes"` - SessionSecret string `json:"sessionSecret"` - SessionMaxAge int `json:"sessionMaxAge"` - SkipVerify bool `json:"skipVerify"` - UsernameClaim string `json:"usernameClaim"` - LogoutURL string `json:"logoutURL"` - SkipLoginPage bool `json:"skipLoginPage"` + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + IssuerURL string `json:"issuerURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURL string `json:"redirectURL"` + Scopes []string `json:"scopes"` + SessionSecret string `json:"sessionSecret"` + SessionMaxAge int `json:"sessionMaxAge"` + SkipVerify bool `json:"skipVerify"` + UsernameClaim string `json:"usernameClaim"` + RoleClaim string `json:"roleClaim"` + AdminRoles []string `json:"adminRoles"` + SupportRoles []string `json:"supportRoles"` + AuthorizationEnabled bool `json:"authorizationEnabled"` + LogoutURL string `json:"logoutURL"` + SkipLoginPage bool `json:"skipLoginPage"` } func defaultAdvancedActionsConfig() AdvancedActionsConfig { @@ -1353,6 +1357,21 @@ func GetBindAddressFromEnv() (string, bool) { // ========================================================================= // Returns the OIDC configuration from environment. Returns nil if OIDC is not enabled. +func parseCommaSeparatedEnv(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + func GetOIDCConfigFromEnv() (*OIDCConfig, error) { enabled := os.Getenv("OIDC_ENABLED") if enabled != "true" && enabled != "1" { @@ -1433,6 +1452,13 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { if config.UsernameClaim == "" { config.UsernameClaim = "preferred_username" } + config.RoleClaim = os.Getenv("OIDC_ROLE_CLAIM") + if config.RoleClaim == "" { + config.RoleClaim = "groups" + } + config.AdminRoles = parseCommaSeparatedEnv(os.Getenv("OIDC_ADMIN_ROLES")) + config.SupportRoles = parseCommaSeparatedEnv(os.Getenv("OIDC_SUPPORT_ROLES")) + config.AuthorizationEnabled = len(config.AdminRoles) > 0 || len(config.SupportRoles) > 0 config.LogoutURL = os.Getenv("OIDC_LOGOUT_URL") return config, nil } diff --git a/pkg/web/auth.go b/pkg/web/auth.go index 7abc2a8..e6b8416 100644 --- a/pkg/web/auth.go +++ b/pkg/web/auth.go @@ -53,6 +53,8 @@ func AuthMiddleware() gin.HandlerFunc { c.Set("userEmail", session.Email) c.Set("userName", session.Name) c.Set("username", session.Username) + c.Set("roles", session.Roles) + c.Set("accessLevel", session.AccessLevel) c.Next() } diff --git a/pkg/web/authorization.go b/pkg/web/authorization.go new file mode 100644 index 0000000..dad8ef2 --- /dev/null +++ b/pkg/web/authorization.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/swissmakers/fail2ban-ui/internal/auth" +) + +const ( + PermissionRead = "read" + PermissionBan = "ban" + PermissionAdmin = "admin" +) + +func RequirePermission(permission string) gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsEnabled() || !auth.AuthorizationEnabled() { + c.Next() + return + } + + sessionValue, exists := c.Get("session") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + + session, ok := sessionValue.(*auth.Session) + if !ok || session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + + if !auth.SessionHasPermission(session, permission) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + c.Abort() + return + } + + c.Next() + } +} + +func userHasAdminAccess(c *gin.Context) bool { + if !auth.IsEnabled() || !auth.AuthorizationEnabled() { + return true + } + sessionValue, exists := c.Get("session") + if !exists { + return false + } + session, ok := sessionValue.(*auth.Session) + return ok && auth.SessionHasPermission(session, PermissionAdmin) +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index a24d8aa..6d2dee1 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1013,6 +1013,18 @@ func parseRetryAfter(value string, fallback time.Duration) time.Duration { // Returns all configured Fail2ban servers. func ListServersHandler(c *gin.Context) { servers := config.ListServers() + if !userHasAdminAccess(c) { + for i := range servers { + servers[i].Host = "" + servers[i].Port = 0 + servers[i].SocketPath = "" + servers[i].ConfigPath = "" + servers[i].SSHUser = "" + servers[i].SSHKeyPath = "" + servers[i].AgentURL = "" + servers[i].AgentSecret = "" + } + } c.JSON(http.StatusOK, gin.H{"servers": servers}) } @@ -2826,22 +2838,29 @@ func GetSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetSettingsHandler called (handlers.go)") s := config.GetSettings() + isAdmin := userHasAdminAccess(c) + if !isAdmin { + s = config.AppSettings{ + Language: s.Language, + AlertCountries: s.AlertCountries, + } + } envPort, envPortSet := config.GetPortFromEnv() envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv() - response := appSettingsResponse{ - AppSettings: s, - PortFromEnv: envPort, - PortEnvSet: envPortSet, - CallbackUrlEnvSet: envCallbackURLSet, - CallbackUrlFromEnv: envCallbackURL, + response := appSettingsResponse{AppSettings: s} + if isAdmin { + response.PortFromEnv = envPort + response.PortEnvSet = envPortSet + response.CallbackUrlEnvSet = envCallbackURLSet + response.CallbackUrlFromEnv = envCallbackURL } - if envPortSet { + if isAdmin && envPortSet { response.Port = envPort } - if envCallbackURLSet { + if isAdmin && envCallbackURLSet { response.CallbackURL = envCallbackURL } @@ -4362,14 +4381,17 @@ func AuthStatusHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "enabled": true, - "authenticated": true, - "skipLoginPage": skipLoginPage, + "enabled": true, + "authenticated": true, + "skipLoginPage": skipLoginPage, + "authorizationEnabled": auth.AuthorizationEnabled(), "user": gin.H{ - "id": session.UserID, - "email": session.Email, - "name": session.Name, - "username": session.Username, + "id": session.UserID, + "email": session.Email, + "name": session.Name, + "username": session.Username, + "roles": session.Roles, + "accessLevel": session.AccessLevel, }, }) } @@ -4388,12 +4410,15 @@ func UserInfoHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "authenticated": true, + "authenticated": true, + "authorizationEnabled": auth.AuthorizationEnabled(), "user": gin.H{ - "id": session.UserID, - "email": session.Email, - "name": session.Name, - "username": session.Username, + "id": session.UserID, + "email": session.Email, + "name": session.Name, + "username": session.Username, + "roles": session.Roles, + "accessLevel": session.AccessLevel, }, }) } diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 00c9f89..50fbf77 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -47,72 +47,72 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { api := r.Group("/api") { // Internal call from frontend to the Fail2ban-UI backend to get the summary of the servers (banned IPs per active jail) - api.GET("/summary", SummaryHandler) + api.GET("/summary", RequirePermission(PermissionRead), SummaryHandler) // External API calls from Fail2ban servers that notify Fail2Ban-UI backend about ban/unban events that where triggered. api.POST("/ban", BanNotificationHandler) api.POST("/unban", UnbanNotificationHandler) // Internal API calls from frontend (e.g. manual actions) to backend to execute Ban / Unban - api.GET("/jails/:jail/banned", ListJailBannedIPsHandler) - api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - api.POST("/jails/:jail/ban/:ip", BanIPHandler) + api.GET("/jails/:jail/banned", RequirePermission(PermissionRead), ListJailBannedIPsHandler) + api.POST("/jails/:jail/unban/:ip", RequirePermission(PermissionBan), UnbanIPHandler) + api.POST("/jails/:jail/ban/:ip", RequirePermission(PermissionBan), BanIPHandler) // Internal API calls for jail-filter management (TODO: rename API-call) - api.GET("/jails/:jail/config", GetJailFilterConfigHandler) - api.POST("/jails/:jail/config", SetJailFilterConfigHandler) - api.POST("/jails/:jail/logpath/test", TestLogpathHandler) - api.GET("/jails/manage", ManageJailsHandler) - api.POST("/jails/manage", UpdateJailManagementHandler) - api.POST("/jails", CreateJailHandler) - api.DELETE("/jails/:jail", DeleteJailHandler) + api.GET("/jails/:jail/config", RequirePermission(PermissionAdmin), GetJailFilterConfigHandler) + api.POST("/jails/:jail/config", RequirePermission(PermissionAdmin), SetJailFilterConfigHandler) + api.POST("/jails/:jail/logpath/test", RequirePermission(PermissionAdmin), TestLogpathHandler) + api.GET("/jails/manage", RequirePermission(PermissionAdmin), ManageJailsHandler) + api.POST("/jails/manage", RequirePermission(PermissionAdmin), UpdateJailManagementHandler) + api.POST("/jails", RequirePermission(PermissionAdmin), CreateJailHandler) + api.DELETE("/jails/:jail", RequirePermission(PermissionAdmin), DeleteJailHandler) // Internal API calls for filter management - api.GET("/filters", ListFiltersHandler) - api.GET("/filters/:filter/content", GetFilterContentHandler) - api.POST("/filters/test", TestFilterHandler) - api.POST("/filters", CreateFilterHandler) - api.DELETE("/filters/:filter", DeleteFilterHandler) + api.GET("/filters", RequirePermission(PermissionAdmin), ListFiltersHandler) + api.GET("/filters/:filter/content", RequirePermission(PermissionAdmin), GetFilterContentHandler) + api.POST("/filters/test", RequirePermission(PermissionAdmin), TestFilterHandler) + api.POST("/filters", RequirePermission(PermissionAdmin), CreateFilterHandler) + api.DELETE("/filters/:filter", RequirePermission(PermissionAdmin), DeleteFilterHandler) // Internal API calls for Fail2ban-UI settings - api.GET("/settings", GetSettingsHandler) - api.POST("/settings", UpdateSettingsHandler) + api.GET("/settings", RequirePermission(PermissionRead), GetSettingsHandler) + api.POST("/settings", RequirePermission(PermissionAdmin), UpdateSettingsHandler) //api.PATCH("/settings", PatchSettingsHandler) - api.POST("/settings/test-email", TestEmailHandler) - api.POST("/settings/test-webhook", TestWebhookHandler) - api.POST("/settings/test-elasticsearch", TestElasticsearchHandler) + api.POST("/settings/test-email", RequirePermission(PermissionAdmin), TestEmailHandler) + api.POST("/settings/test-webhook", RequirePermission(PermissionAdmin), TestWebhookHandler) + api.POST("/settings/test-elasticsearch", RequirePermission(PermissionAdmin), TestElasticsearchHandler) // Internal API calls for advanced actions - api.GET("/advanced-actions/blocks", ListPermanentBlocksHandler) - api.DELETE("/advanced-actions/blocks", ClearPermanentBlocksHandler) - api.POST("/advanced-actions/test", AdvancedActionsTestHandler) + api.GET("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ListPermanentBlocksHandler) + api.DELETE("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ClearPermanentBlocksHandler) + api.POST("/advanced-actions/test", RequirePermission(PermissionAdmin), AdvancedActionsTestHandler) // Internal API calls for Fail2ban-UI server management - api.GET("/servers", ListServersHandler) - api.POST("/servers", UpsertServerHandler) - api.DELETE("/servers/:id", DeleteServerHandler) - api.POST("/servers/:id/default", SetDefaultServerHandler) - api.GET("/ssh/keys", ListSSHKeysHandler) - api.POST("/servers/:id/test", TestServerHandler) + api.GET("/servers", RequirePermission(PermissionRead), ListServersHandler) + api.POST("/servers", RequirePermission(PermissionAdmin), UpsertServerHandler) + api.DELETE("/servers/:id", RequirePermission(PermissionAdmin), DeleteServerHandler) + api.POST("/servers/:id/default", RequirePermission(PermissionAdmin), SetDefaultServerHandler) + api.GET("/ssh/keys", RequirePermission(PermissionAdmin), ListSSHKeysHandler) + api.POST("/servers/:id/test", RequirePermission(PermissionAdmin), TestServerHandler) // Internal API to restart Fail2ban - api.POST("/fail2ban/restart", RestartFail2banHandler) + api.POST("/fail2ban/restart", RequirePermission(PermissionAdmin), RestartFail2banHandler) // Internal API calls to get the stats and insights about bans - api.GET("/events/bans", ListBanEventsHandler) - api.DELETE("/events/bans", ClearBanEventsHandler) - api.GET("/events/bans/stats", BanStatisticsHandler) - api.GET("/events/bans/insights", BanInsightsHandler) - api.GET("/events/bans/:id", GetBanEventHandler) - api.GET("/threat-intel/:ip", ThreatIntelHandler) + api.GET("/events/bans", RequirePermission(PermissionRead), ListBanEventsHandler) + api.DELETE("/events/bans", RequirePermission(PermissionAdmin), ClearBanEventsHandler) + api.GET("/events/bans/stats", RequirePermission(PermissionRead), BanStatisticsHandler) + api.GET("/events/bans/insights", RequirePermission(PermissionRead), BanInsightsHandler) + api.GET("/events/bans/:id", RequirePermission(PermissionRead), GetBanEventHandler) + api.GET("/threat-intel/:ip", RequirePermission(PermissionRead), ThreatIntelHandler) // WebSocket endpoint - api.GET("/ws", WebSocketHandler(hub)) + api.GET("/ws", RequirePermission(PermissionRead), WebSocketHandler(hub)) // API to healthchecks (mainly used by agent) api.GET("/healthcheck/callback", HealthcheckCallbackSecret) // External API to get the version of the Fail2ban-UI and check for updates - api.GET("/version", GetVersionHandler) + api.GET("/version", RequirePermission(PermissionRead), GetVersionHandler) } } diff --git a/pkg/web/static/js/auth.js b/pkg/web/static/js/auth.js index 1fc3f38..d73eb4d 100644 --- a/pkg/web/static/js/auth.js +++ b/pkg/web/static/js/auth.js @@ -8,6 +8,7 @@ let authEnabled = false; let isAuthenticated = false; let currentUser = null; +let authorizationEnabled = false; // ========================================================================= // Check Authentication Status @@ -49,6 +50,7 @@ async function checkAuthStatus() { const data = await response.json(); authEnabled = data.enabled || false; isAuthenticated = data.authenticated || false; + authorizationEnabled = data.authorizationEnabled || false; const skipLoginPageFlag = data.skipLoginPage || false; if (authEnabled) { @@ -151,6 +153,27 @@ function showLoginPage() { } } +function hasAccess(requiredLevel) { + if (!authorizationEnabled || !authEnabled) return true; + if (!requiredLevel) return true; + const accessLevel = currentUser && currentUser.accessLevel ? currentUser.accessLevel : ''; + if (accessLevel === 'admin') return true; + return requiredLevel === 'support' && accessLevel === 'support'; +} + +function applyAuthorizationUI() { + document.querySelectorAll('[data-min-access]').forEach(function(el) { + const required = el.getAttribute('data-min-access'); + if (hasAccess(required)) { + el.classList.remove('hidden'); + el.removeAttribute('aria-hidden'); + } else { + el.classList.add('hidden'); + el.setAttribute('aria-hidden', 'true'); + } + }); +} + function showMainContent() { const loginPage = document.getElementById('loginPage'); const mainContent = document.getElementById('mainContent'); @@ -176,6 +199,7 @@ function showMainContent() { footer.style.display = 'block'; footer.classList.remove('hidden'); } + applyAuthorizationUI(); } function showAuthenticatedUI() { diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index 826ef05..26a4d77 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -309,6 +309,9 @@ function updateRestartBanner() { } function showSection(sectionId) { + if ((sectionId === 'filterSection' || sectionId === 'settingsSection') && typeof hasAccess === 'function' && !hasAccess('admin')) { + sectionId = 'dashboardSection'; + } // hide all sections document.getElementById('dashboardSection').classList.add('hidden'); document.getElementById('filterSection').classList.add('hidden'); diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 7edb1fa..a96a54f 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -123,8 +123,8 @@