diff --git a/weed/admin/dash/csrf.go b/weed/admin/dash/csrf.go index bd36be70d..3262238f0 100644 --- a/weed/admin/dash/csrf.go +++ b/weed/admin/dash/csrf.go @@ -12,6 +12,10 @@ import ( const sessionCSRFTokenKey = "csrf_token" +func SessionCSRFTokenKey() string { + return sessionCSRFTokenKey +} + func generateCSRFToken() (string, error) { tokenBytes := make([]byte, 32) if _, err := rand.Read(tokenBytes); err != nil { @@ -20,6 +24,11 @@ func generateCSRFToken() (string, error) { return hex.EncodeToString(tokenBytes), nil } +// GenerateSessionToken creates a cryptographically secure token for session data. +func GenerateSessionToken() (string, error) { + return generateCSRFToken() +} + func getOrCreateSessionCSRFToken(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) { if existing, ok := session.Values[sessionCSRFTokenKey].(string); ok && existing != "" { return existing, nil diff --git a/weed/admin/dash/oidc_auth.go b/weed/admin/dash/oidc_auth.go new file mode 100644 index 000000000..8378b0340 --- /dev/null +++ b/weed/admin/dash/oidc_auth.go @@ -0,0 +1,544 @@ +package dash + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gorilla/sessions" + iamoidc "github.com/seaweedfs/seaweedfs/weed/iam/oidc" + "github.com/seaweedfs/seaweedfs/weed/iam/providers" + "golang.org/x/oauth2" +) + +const ( + oidcSessionStateKey = "oidc_state" + oidcSessionNonceKey = "oidc_nonce" + oidcSessionIssuedAtKey = "oidc_issued_at_unix" + oidcStateTTL = 10 * time.Minute +) + +type OIDCRoleMappingRuleConfig struct { + Claim string `mapstructure:"claim"` + Value string `mapstructure:"value"` + Role string `mapstructure:"role"` +} + +type OIDCRoleMappingConfig struct { + DefaultRole string `mapstructure:"default_role"` + Rules []OIDCRoleMappingRuleConfig `mapstructure:"rules"` +} + +type OIDCAuthConfig struct { + Enabled bool `mapstructure:"enabled"` + Issuer string `mapstructure:"issuer"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + RedirectURL string `mapstructure:"redirect_url"` + Scopes []string `mapstructure:"scopes"` + JWKSURI string `mapstructure:"jwks_uri"` + TLSCACert string `mapstructure:"tls_ca_cert"` + TLSInsecureSkipVerify bool `mapstructure:"tls_insecure_skip_verify"` + RoleMapping OIDCRoleMappingConfig `mapstructure:"role_mapping"` +} + +type oidcDiscoveryDocument struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +type OIDCLoginResult struct { + Username string + Role string + TokenExpiration *time.Time +} + +type OIDCAuthService struct { + config OIDCAuthConfig + roleMapping *providers.RoleMapping + httpClient *http.Client + oauthConfig *oauth2.Config + validator *iamoidc.OIDCProvider +} + +func NewOIDCAuthService(config OIDCAuthConfig) (*OIDCAuthService, error) { + if !config.Enabled { + return nil, nil + } + + normalized := normalizeOIDCAuthConfig(config) + if err := normalized.Validate(); err != nil { + return nil, err + } + + httpClient, err := createOIDCHTTPClient(normalized) + if err != nil { + return nil, err + } + + discovery, err := fetchOIDCDiscoveryDocument(httpClient, normalized.Issuer) + if err != nil { + return nil, err + } + + jwksURI := normalized.JWKSURI + if jwksURI == "" { + jwksURI = discovery.JWKSURI + } + + roleMapping := normalized.toRoleMapping() + + validator := iamoidc.NewOIDCProvider("admin-ui-oidc") + if err := validator.Initialize(&iamoidc.OIDCConfig{ + Issuer: normalized.Issuer, + ClientID: normalized.ClientID, + ClientSecret: normalized.ClientSecret, + JWKSUri: jwksURI, + Scopes: normalized.EffectiveScopes(), + RoleMapping: roleMapping, + TLSCACert: normalized.TLSCACert, + TLSInsecureSkipVerify: normalized.TLSInsecureSkipVerify, + }); err != nil { + return nil, fmt.Errorf("initialize OIDC token validator: %w", err) + } + + return &OIDCAuthService{ + config: normalized, + roleMapping: roleMapping, + httpClient: httpClient, + oauthConfig: &oauth2.Config{ + ClientID: normalized.ClientID, + ClientSecret: normalized.ClientSecret, + RedirectURL: normalized.RedirectURL, + Scopes: normalized.EffectiveScopes(), + Endpoint: oauth2.Endpoint{ + AuthURL: discovery.AuthorizationEndpoint, + TokenURL: discovery.TokenEndpoint, + }, + }, + validator: validator, + }, nil +} + +func (c OIDCAuthConfig) Validate() error { + if !c.Enabled { + return nil + } + + if c.Issuer == "" { + return fmt.Errorf("admin.oidc.issuer is required when OIDC is enabled") + } + if c.ClientID == "" { + return fmt.Errorf("admin.oidc.client_id is required when OIDC is enabled") + } + if c.ClientSecret == "" { + return fmt.Errorf("admin.oidc.client_secret is required when OIDC is enabled") + } + if c.RedirectURL == "" { + return fmt.Errorf("admin.oidc.redirect_url is required when OIDC is enabled") + } + + redirectURL, err := url.Parse(c.RedirectURL) + if err != nil { + return fmt.Errorf("admin.oidc.redirect_url is invalid: %w", err) + } + if !redirectURL.IsAbs() { + return fmt.Errorf("admin.oidc.redirect_url must be absolute") + } + + if c.TLSCACert != "" && !filepath.IsAbs(c.TLSCACert) { + return fmt.Errorf("admin.oidc.tls_ca_cert must be an absolute path") + } + + if len(c.RoleMapping.Rules) == 0 && c.RoleMapping.DefaultRole == "" { + return fmt.Errorf("admin.oidc.role_mapping must include at least one rule or default_role") + } + + if c.RoleMapping.DefaultRole != "" && !isSupportedAdminRole(c.RoleMapping.DefaultRole) { + return fmt.Errorf("admin.oidc.role_mapping.default_role must be one of: admin, readonly") + } + + for i, rule := range c.RoleMapping.Rules { + if strings.TrimSpace(rule.Claim) == "" { + return fmt.Errorf("admin.oidc.role_mapping.rules[%d].claim is required", i) + } + if strings.TrimSpace(rule.Value) == "" { + return fmt.Errorf("admin.oidc.role_mapping.rules[%d].value is required", i) + } + if !isSupportedAdminRole(rule.Role) { + return fmt.Errorf("admin.oidc.role_mapping.rules[%d].role must be one of: admin, readonly", i) + } + } + + return nil +} + +func (c OIDCAuthConfig) EffectiveScopes() []string { + if len(c.Scopes) == 0 { + return []string{"openid", "profile", "email"} + } + + scopes := make([]string, 0, len(c.Scopes)+1) + seen := make(map[string]struct{}, len(c.Scopes)+1) + + for _, scope := range c.Scopes { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, exists := seen[scope]; exists { + continue + } + seen[scope] = struct{}{} + scopes = append(scopes, scope) + } + + if _, exists := seen["openid"]; !exists { + scopes = append(scopes, "openid") + } + + return scopes +} + +func (c OIDCAuthConfig) toRoleMapping() *providers.RoleMapping { + roleMapping := &providers.RoleMapping{ + DefaultRole: normalizeAdminRole(c.RoleMapping.DefaultRole), + } + + for _, rule := range c.RoleMapping.Rules { + roleMapping.Rules = append(roleMapping.Rules, providers.MappingRule{ + Claim: strings.TrimSpace(rule.Claim), + Value: strings.TrimSpace(rule.Value), + Role: normalizeAdminRole(rule.Role), + }) + } + + return roleMapping +} + +func (s *OIDCAuthService) BeginLogin(session *sessions.Session, r *http.Request, w http.ResponseWriter) (string, error) { + if s == nil { + return "", fmt.Errorf("OIDC auth is not configured") + } + if session == nil { + return "", fmt.Errorf("session is nil") + } + + state, err := generateAuthFlowSecret() + if err != nil { + return "", fmt.Errorf("generate OIDC state: %w", err) + } + nonce, err := generateAuthFlowSecret() + if err != nil { + return "", fmt.Errorf("generate OIDC nonce: %w", err) + } + + session.Values[oidcSessionStateKey] = state + session.Values[oidcSessionNonceKey] = nonce + session.Values[oidcSessionIssuedAtKey] = time.Now().Unix() + if err := session.Save(r, w); err != nil { + return "", fmt.Errorf("save OIDC login session state: %w", err) + } + + return s.oauthConfig.AuthCodeURL( + state, + oauth2.AccessTypeOnline, + oauth2.SetAuthURLParam("nonce", nonce), + ), nil +} + +func (s *OIDCAuthService) CompleteLogin(session *sessions.Session, r *http.Request, w http.ResponseWriter) (*OIDCLoginResult, error) { + if s == nil { + return nil, fmt.Errorf("OIDC auth is not configured") + } + if session == nil { + return nil, fmt.Errorf("session is nil") + } + + if oidcError := strings.TrimSpace(r.URL.Query().Get("error")); oidcError != "" { + description := strings.TrimSpace(r.URL.Query().Get("error_description")) + if description != "" { + return nil, fmt.Errorf("OIDC authorization failed: %s (%s)", oidcError, description) + } + return nil, fmt.Errorf("OIDC authorization failed: %s", oidcError) + } + + state := strings.TrimSpace(r.URL.Query().Get("state")) + code := strings.TrimSpace(r.URL.Query().Get("code")) + if state == "" || code == "" { + return nil, fmt.Errorf("missing OIDC callback state or code") + } + + expectedState, _ := session.Values[oidcSessionStateKey].(string) + expectedNonce, _ := session.Values[oidcSessionNonceKey].(string) + issuedAtUnix, ok := sessionValueToInt64(session.Values[oidcSessionIssuedAtKey]) + if !ok { + return nil, fmt.Errorf("missing OIDC login session state") + } + + delete(session.Values, oidcSessionStateKey) + delete(session.Values, oidcSessionNonceKey) + delete(session.Values, oidcSessionIssuedAtKey) + if err := session.Save(r, w); err != nil { + return nil, fmt.Errorf("clear OIDC login session state: %w", err) + } + + if expectedState == "" || expectedNonce == "" { + return nil, fmt.Errorf("missing OIDC login session state") + } + if subtle.ConstantTimeCompare([]byte(state), []byte(expectedState)) != 1 { + return nil, fmt.Errorf("invalid OIDC callback state") + } + if time.Since(time.Unix(issuedAtUnix, 0)) > oidcStateTTL { + return nil, fmt.Errorf("OIDC callback has expired; please sign in again") + } + + ctx := context.WithValue(r.Context(), oauth2.HTTPClient, s.httpClient) + token, err := s.oauthConfig.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("exchange OIDC code for token: %w", err) + } + + idToken, err := extractIDToken(token) + if err != nil { + return nil, err + } + + claims, err := s.validator.ValidateToken(ctx, idToken) + if err != nil { + return nil, fmt.Errorf("validate OIDC ID token: %w", err) + } + + nonce, ok := claims.GetClaimString("nonce") + if !ok || subtle.ConstantTimeCompare([]byte(nonce), []byte(expectedNonce)) != 1 { + return nil, fmt.Errorf("invalid OIDC token nonce") + } + + mappedRoles := mapClaimsToRoles(claims, s.roleMapping) + role, err := resolveAdminRole(mappedRoles) + if err != nil { + return nil, err + } + + username := preferredOIDCUsername(claims) + if username == "" { + return nil, fmt.Errorf("OIDC token is missing a usable username claim") + } + + result := &OIDCLoginResult{ + Username: username, + Role: role, + } + if !claims.ExpiresAt.IsZero() { + expiresAt := claims.ExpiresAt + result.TokenExpiration = &expiresAt + } + return result, nil +} + +func createOIDCHTTPClient(config OIDCAuthConfig) (*http.Client, error) { + tlsConfig := &tls.Config{ + InsecureSkipVerify: config.TLSInsecureSkipVerify, + MinVersion: tls.VersionTLS12, + } + + if config.TLSCACert != "" { + caCertBytes, err := os.ReadFile(config.TLSCACert) + if err != nil { + return nil, fmt.Errorf("read OIDC CA certificate: %w", err) + } + rootCAs, _ := x509.SystemCertPool() + if rootCAs == nil { + rootCAs = x509.NewCertPool() + } + if !rootCAs.AppendCertsFromPEM(caCertBytes) { + return nil, fmt.Errorf("append OIDC CA certificate from %s", config.TLSCACert) + } + tlsConfig.RootCAs = rootCAs + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} + +func fetchOIDCDiscoveryDocument(httpClient *http.Client, issuer string) (*oidcDiscoveryDocument, error) { + discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequest(http.MethodGet, discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("build OIDC discovery request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch OIDC discovery document: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC discovery returned status %d", resp.StatusCode) + } + + var discovery oidcDiscoveryDocument + if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { + return nil, fmt.Errorf("decode OIDC discovery document: %w", err) + } + + if strings.TrimSpace(discovery.AuthorizationEndpoint) == "" { + return nil, fmt.Errorf("OIDC discovery document is missing authorization_endpoint") + } + if strings.TrimSpace(discovery.TokenEndpoint) == "" { + return nil, fmt.Errorf("OIDC discovery document is missing token_endpoint") + } + + return &discovery, nil +} + +func extractIDToken(token *oauth2.Token) (string, error) { + if token == nil { + return "", fmt.Errorf("OIDC token exchange returned no token") + } + + rawIDToken := token.Extra("id_token") + switch value := rawIDToken.(type) { + case string: + if strings.TrimSpace(value) == "" { + return "", fmt.Errorf("OIDC token exchange returned an empty id_token") + } + return value, nil + default: + return "", fmt.Errorf("OIDC token exchange did not include id_token") + } +} + +func mapClaimsToRoles(claims *providers.TokenClaims, mapping *providers.RoleMapping) []string { + if claims == nil || mapping == nil { + return nil + } + + roles := make([]string, 0, len(mapping.Rules)+1) + seen := make(map[string]struct{}, len(mapping.Rules)+1) + for _, rule := range mapping.Rules { + if rule.Matches(claims) { + role := normalizeAdminRole(rule.Role) + if role == "" { + continue + } + if _, exists := seen[role]; exists { + continue + } + seen[role] = struct{}{} + roles = append(roles, role) + } + } + + if len(roles) == 0 { + defaultRole := normalizeAdminRole(mapping.DefaultRole) + if defaultRole != "" { + roles = append(roles, defaultRole) + } + } + + return roles +} + +func resolveAdminRole(roles []string) (string, error) { + hasReadonly := false + for _, role := range roles { + role = normalizeAdminRole(role) + if role == "admin" { + return "admin", nil + } + if role == "readonly" { + hasReadonly = true + } + } + if hasReadonly { + return "readonly", nil + } + return "", fmt.Errorf("OIDC user does not map to an allowed admin role") +} + +func preferredOIDCUsername(claims *providers.TokenClaims) string { + if claims == nil { + return "" + } + + claimCandidates := []string{"preferred_username", "email", "name", "sub"} + for _, key := range claimCandidates { + if value, exists := claims.GetClaimString(key); exists && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + + if strings.TrimSpace(claims.Subject) != "" { + return strings.TrimSpace(claims.Subject) + } + return "" +} + +func generateAuthFlowSecret() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func sessionValueToInt64(value interface{}) (int64, bool) { + switch v := value.(type) { + case int64: + return v, true + case int: + return int64(v), true + case float64: + return int64(v), true + default: + return 0, false + } +} + +func normalizeOIDCAuthConfig(config OIDCAuthConfig) OIDCAuthConfig { + config.Issuer = strings.TrimSpace(config.Issuer) + config.ClientID = strings.TrimSpace(config.ClientID) + config.ClientSecret = strings.TrimSpace(config.ClientSecret) + config.RedirectURL = strings.TrimSpace(config.RedirectURL) + config.JWKSURI = strings.TrimSpace(config.JWKSURI) + config.TLSCACert = strings.TrimSpace(config.TLSCACert) + config.RoleMapping.DefaultRole = normalizeAdminRole(config.RoleMapping.DefaultRole) + for i := range config.RoleMapping.Rules { + config.RoleMapping.Rules[i].Claim = strings.TrimSpace(config.RoleMapping.Rules[i].Claim) + config.RoleMapping.Rules[i].Value = strings.TrimSpace(config.RoleMapping.Rules[i].Value) + config.RoleMapping.Rules[i].Role = normalizeAdminRole(config.RoleMapping.Rules[i].Role) + } + return config +} + +func isSupportedAdminRole(role string) bool { + switch normalizeAdminRole(role) { + case "admin", "readonly": + return true + default: + return false + } +} + +func normalizeAdminRole(role string) string { + return strings.ToLower(strings.TrimSpace(role)) +} diff --git a/weed/admin/dash/oidc_auth_test.go b/weed/admin/dash/oidc_auth_test.go new file mode 100644 index 000000000..967d81660 --- /dev/null +++ b/weed/admin/dash/oidc_auth_test.go @@ -0,0 +1,67 @@ +package dash + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/iam/providers" +) + +func TestOIDCAuthConfigValidateRequiresRoleMapping(t *testing.T) { + config := OIDCAuthConfig{ + Enabled: true, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + ClientSecret: "client-secret", + RedirectURL: "https://admin.example.com/login/oidc/callback", + } + + if err := config.Validate(); err == nil { + t.Fatalf("expected validation error when role_mapping is missing") + } +} + +func TestOIDCAuthConfigEffectiveScopesIncludesOpenID(t *testing.T) { + config := OIDCAuthConfig{ + Scopes: []string{"profile", "email", "profile"}, + } + + scopes := config.EffectiveScopes() + expected := []string{"profile", "email", "openid"} + if len(scopes) != len(expected) { + t.Fatalf("expected %d scopes, got %d (%v)", len(expected), len(scopes), scopes) + } + for i, scope := range expected { + if scopes[i] != scope { + t.Fatalf("expected scope[%d]=%q, got %q", i, scope, scopes[i]) + } + } +} + +func TestMapClaimsToRolesAndResolveAdminRole(t *testing.T) { + claims := &providers.TokenClaims{ + Claims: map[string]interface{}{ + "groups": []interface{}{"seaweedfs-readers", "seaweedfs-admins"}, + }, + } + + roleMapping := &providers.RoleMapping{ + Rules: []providers.MappingRule{ + {Claim: "groups", Value: "seaweedfs-readers", Role: "readonly"}, + {Claim: "groups", Value: "seaweedfs-admins", Role: "admin"}, + }, + DefaultRole: "readonly", + } + + roles := mapClaimsToRoles(claims, roleMapping) + if len(roles) != 2 { + t.Fatalf("expected 2 mapped roles, got %d (%v)", len(roles), roles) + } + + role, err := resolveAdminRole(roles) + if err != nil { + t.Fatalf("expected resolved role, got error: %v", err) + } + if role != "admin" { + t.Fatalf("expected admin role, got %s", role) + } +} diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 357a30129..f7b120bf2 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -20,6 +20,7 @@ import ( type AdminHandlers struct { adminServer *dash.AdminServer sessionStore sessions.Store + authConfig AuthConfig authHandlers *AuthHandlers clusterHandlers *ClusterHandlers fileBrowserHandlers *FileBrowserHandlers @@ -31,8 +32,8 @@ type AdminHandlers struct { } // NewAdminHandlers creates a new instance of AdminHandlers -func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *AdminHandlers { - authHandlers := NewAuthHandlers(adminServer, store) +func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store, authConfig AuthConfig) *AdminHandlers { + authHandlers := NewAuthHandlers(adminServer, store, authConfig) clusterHandlers := NewClusterHandlers(adminServer) fileBrowserHandlers := NewFileBrowserHandlers(adminServer) userHandlers := NewUserHandlers(adminServer) @@ -43,6 +44,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi return &AdminHandlers{ adminServer: adminServer, sessionStore: store, + authConfig: authConfig, authHandlers: authHandlers, clusterHandlers: clusterHandlers, fileBrowserHandlers: fileBrowserHandlers, @@ -55,7 +57,7 @@ func NewAdminHandlers(adminServer *dash.AdminServer, store sessions.Store) *Admi } // SetupRoutes configures all the routes for the admin interface -func (h *AdminHandlers) SetupRoutes(r *mux.Router, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string, enableUI bool) { +func (h *AdminHandlers) SetupRoutes(r *mux.Router, enableUI bool) { // Health check (no auth required) r.HandleFunc("/health", h.HealthCheck).Methods(http.MethodGet) @@ -72,10 +74,14 @@ func (h *AdminHandlers) SetupRoutes(r *mux.Router, authRequired bool, adminUser, return } - if authRequired { + if h.authConfig.AuthRequired() { // Authentication routes (no auth required) r.HandleFunc("/login", h.authHandlers.ShowLogin).Methods(http.MethodGet) - r.Handle("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)).Methods(http.MethodPost) + r.Handle("/login", h.authHandlers.HandleLogin()).Methods(http.MethodPost) + if h.authConfig.OIDCAuthEnabled() { + r.HandleFunc("/login/oidc", h.authHandlers.HandleOIDCLogin).Methods(http.MethodGet) + r.HandleFunc("/login/oidc/callback", h.authHandlers.HandleOIDCCallback).Methods(http.MethodGet) + } r.HandleFunc("/logout", h.authHandlers.HandleLogout).Methods(http.MethodGet) protected := r.NewRoute().Subrouter() diff --git a/weed/admin/handlers/admin_handlers_routes_test.go b/weed/admin/handlers/admin_handlers_routes_test.go index ab33922e3..ce2e4c00c 100644 --- a/weed/admin/handlers/admin_handlers_routes_test.go +++ b/weed/admin/handlers/admin_handlers_routes_test.go @@ -13,7 +13,7 @@ import ( func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_NoAuth(t *testing.T) { router := mux.NewRouter() - newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + newRouteTestAdminHandlers(AuthConfig{}).SetupRoutes(router, true) if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") { t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in no-auth mode") @@ -26,7 +26,7 @@ func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_NoAuth(t *testing.T) { func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_WithAuth(t *testing.T) { router := mux.NewRouter() - newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) + newRouteTestAdminHandlers(AuthConfig{AdminUser: "admin", AdminPassword: "password"}).SetupRoutes(router, true) if !hasRoute(router, http.MethodGet, "/api/plugin/scheduler-states") { t.Fatalf("expected GET /api/plugin/scheduler-states to be registered in auth mode") @@ -39,7 +39,7 @@ func TestSetupRoutes_RegistersPluginSchedulerStatesAPI_WithAuth(t *testing.T) { func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) { router := mux.NewRouter() - newRouteTestAdminHandlers().SetupRoutes(router, false, "", "", "", "", true) + newRouteTestAdminHandlers(AuthConfig{}).SetupRoutes(router, true) assertHasRoute(t, router, http.MethodGet, "/plugin") assertHasRoute(t, router, http.MethodGet, "/plugin/configuration") @@ -52,7 +52,7 @@ func TestSetupRoutes_RegistersPluginPages_NoAuth(t *testing.T) { func TestSetupRoutes_RegistersPluginPages_WithAuth(t *testing.T) { router := mux.NewRouter() - newRouteTestAdminHandlers().SetupRoutes(router, true, "admin", "password", "", "", true) + newRouteTestAdminHandlers(AuthConfig{AdminUser: "admin", AdminPassword: "password"}).SetupRoutes(router, true) assertHasRoute(t, router, http.MethodGet, "/plugin") assertHasRoute(t, router, http.MethodGet, "/plugin/configuration") @@ -62,13 +62,24 @@ func TestSetupRoutes_RegistersPluginPages_WithAuth(t *testing.T) { assertHasRoute(t, router, http.MethodGet, "/plugin/monitoring") } -func newRouteTestAdminHandlers() *AdminHandlers { +func TestSetupRoutes_RegistersOIDCRoutes_WhenEnabled(t *testing.T) { + router := mux.NewRouter() + + newRouteTestAdminHandlers(AuthConfig{OIDCAuth: &dash.OIDCAuthService{}}).SetupRoutes(router, true) + + assertHasRoute(t, router, http.MethodGet, "/login") + assertHasRoute(t, router, http.MethodGet, "/login/oidc") + assertHasRoute(t, router, http.MethodGet, "/login/oidc/callback") +} + +func newRouteTestAdminHandlers(authConfig AuthConfig) *AdminHandlers { adminServer := &dash.AdminServer{} store := sessions.NewCookieStore([]byte("test-session-key")) return &AdminHandlers{ adminServer: adminServer, sessionStore: store, - authHandlers: &AuthHandlers{adminServer: adminServer, sessionStore: store}, + authConfig: authConfig, + authHandlers: &AuthHandlers{adminServer: adminServer, sessionStore: store, authConfig: authConfig}, clusterHandlers: &ClusterHandlers{adminServer: adminServer}, fileBrowserHandlers: &FileBrowserHandlers{adminServer: adminServer}, userHandlers: &UserHandlers{adminServer: adminServer}, diff --git a/weed/admin/handlers/auth_config.go b/weed/admin/handlers/auth_config.go new file mode 100644 index 000000000..12803b33d --- /dev/null +++ b/weed/admin/handlers/auth_config.go @@ -0,0 +1,23 @@ +package handlers + +import "github.com/seaweedfs/seaweedfs/weed/admin/dash" + +type AuthConfig struct { + AdminUser string + AdminPassword string + ReadOnlyUser string + ReadOnlyPassword string + OIDCAuth *dash.OIDCAuthService +} + +func (c AuthConfig) LocalAuthEnabled() bool { + return c.AdminPassword != "" +} + +func (c AuthConfig) OIDCAuthEnabled() bool { + return c.OIDCAuth != nil +} + +func (c AuthConfig) AuthRequired() bool { + return c.LocalAuthEnabled() || c.OIDCAuthEnabled() +} diff --git a/weed/admin/handlers/auth_handlers.go b/weed/admin/handlers/auth_handlers.go index dd401b1f3..dadc46a17 100644 --- a/weed/admin/handlers/auth_handlers.go +++ b/weed/admin/handlers/auth_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/gorilla/sessions" "github.com/seaweedfs/seaweedfs/weed/admin/dash" @@ -13,13 +14,15 @@ import ( type AuthHandlers struct { adminServer *dash.AdminServer sessionStore sessions.Store + authConfig AuthConfig } // NewAuthHandlers creates a new instance of AuthHandlers -func NewAuthHandlers(adminServer *dash.AdminServer, store sessions.Store) *AuthHandlers { +func NewAuthHandlers(adminServer *dash.AdminServer, store sessions.Store, authConfig AuthConfig) *AuthHandlers { return &AuthHandlers{ adminServer: adminServer, sessionStore: store, + authConfig: authConfig, } } @@ -49,7 +52,13 @@ func (a *AuthHandlers) ShowLogin(w http.ResponseWriter, r *http.Request) { // Render login template w.Header().Set("Content-Type", "text/html") - loginComponent := layout.LoginForm("SeaweedFS Admin", errorMessage, csrfToken) + loginComponent := layout.LoginForm( + "SeaweedFS Admin", + errorMessage, + csrfToken, + a.authConfig.LocalAuthEnabled(), + a.authConfig.OIDCAuthEnabled(), + ) if err := loginComponent.Render(r.Context(), w); err != nil { writeJSONError(w, http.StatusInternalServerError, "Failed to render login template: "+err.Error()) return @@ -57,11 +66,116 @@ func (a *AuthHandlers) ShowLogin(w http.ResponseWriter, r *http.Request) { } // HandleLogin handles login form submission -func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) http.HandlerFunc { - return a.adminServer.HandleLogin(a.sessionStore, adminUser, adminPassword, readOnlyUser, readOnlyPassword) +func (a *AuthHandlers) HandleLogin() http.HandlerFunc { + if !a.authConfig.LocalAuthEnabled() { + return func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login?error=Local username/password login is disabled", http.StatusSeeOther) + } + } + + return a.adminServer.HandleLogin( + a.sessionStore, + a.authConfig.AdminUser, + a.authConfig.AdminPassword, + a.authConfig.ReadOnlyUser, + a.authConfig.ReadOnlyPassword, + ) +} + +// HandleOIDCLogin starts the OIDC authorization code flow. +func (a *AuthHandlers) HandleOIDCLogin(w http.ResponseWriter, r *http.Request) { + if !a.authConfig.OIDCAuthEnabled() { + http.Redirect(w, r, "/login?error=OIDC login is disabled", http.StatusSeeOther) + return + } + + session, err := a.sessionStore.Get(r, dash.SessionName()) + if err != nil { + http.Redirect(w, r, "/login?error=Unable to initialize session", http.StatusSeeOther) + return + } + + loginURL, err := a.authConfig.OIDCAuth.BeginLogin(session, r, w) + if err != nil { + glog.Errorf("Failed to start OIDC login flow: %v", err) + http.Redirect(w, r, "/login?error=Unable to start OIDC login", http.StatusSeeOther) + return + } + + http.Redirect(w, r, loginURL, http.StatusSeeOther) +} + +// HandleOIDCCallback handles the OIDC authorization code callback. +func (a *AuthHandlers) HandleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if !a.authConfig.OIDCAuthEnabled() { + http.Redirect(w, r, "/login?error=OIDC login is disabled", http.StatusSeeOther) + return + } + + session, err := a.sessionStore.Get(r, dash.SessionName()) + if err != nil { + http.Redirect(w, r, "/login?error=Unable to initialize session", http.StatusSeeOther) + return + } + + result, err := a.authConfig.OIDCAuth.CompleteLogin(session, r, w) + if err != nil { + glog.Warningf("OIDC callback failed: %v", err) + http.Redirect(w, r, "/login?error=OIDC login failed", http.StatusSeeOther) + return + } + + for key := range session.Values { + delete(session.Values, key) + } + + session.Values["authenticated"] = true + session.Values["username"] = result.Username + session.Values["role"] = result.Role + session.Values["auth_provider"] = "oidc" + + csrfToken, err := dash.GenerateSessionToken() + if err != nil { + glog.Errorf("Failed to create session CSRF token for OIDC user %s: %v", result.Username, err) + http.Redirect(w, r, "/login?error=Unable to initialize session", http.StatusSeeOther) + return + } + session.Values[dash.SessionCSRFTokenKey()] = csrfToken + + // OIDC sessions must not outlive the source token. + if result.TokenExpiration != nil { + session.Options = cloneSessionOptions(session.Options) + maxAgeFromToken := int(time.Until(*result.TokenExpiration).Seconds()) + if maxAgeFromToken < 1 { + http.Redirect(w, r, "/login?error=OIDC token has expired", http.StatusSeeOther) + return + } + if session.Options == nil { + session.Options = &sessions.Options{Path: "/", HttpOnly: true} + } + if session.Options.MaxAge <= 0 || maxAgeFromToken < session.Options.MaxAge { + session.Options.MaxAge = maxAgeFromToken + } + } + + if err := session.Save(r, w); err != nil { + glog.Errorf("Failed to save OIDC session for user %s: %v", result.Username, err) + http.Redirect(w, r, "/login?error=Unable to save session", http.StatusSeeOther) + return + } + + http.Redirect(w, r, "/admin", http.StatusSeeOther) } // HandleLogout handles user logout func (a *AuthHandlers) HandleLogout(w http.ResponseWriter, r *http.Request) { a.adminServer.HandleLogout(a.sessionStore, w, r) } + +func cloneSessionOptions(options *sessions.Options) *sessions.Options { + if options == nil { + return nil + } + cloned := *options + return &cloned +} diff --git a/weed/command/admin.go b/weed/command/admin.go index f843af39d..32365d4f4 100644 --- a/weed/command/admin.go +++ b/weed/command/admin.go @@ -91,12 +91,15 @@ var cmdAdmin = &Command{ - Without dataDir, all configuration is kept in memory only Authentication: - - If adminPassword is not set, the admin interface runs without authentication + - Local auth: set adminUser/adminPassword for username/password login - If adminPassword is set, users must login with adminUser/adminPassword (full access) - Optional read-only access: set readOnlyUser and readOnlyPassword for view-only access - Read-only users can view cluster status and configurations but cannot make changes - IMPORTANT: When read-only credentials are configured, adminPassword MUST also be set - This ensures an admin account exists to manage and authorize read-only access + - OIDC auth: configure [admin.oidc] in security.toml for Authorization Code flow + - OIDC role mapping must resolve users to admin or readonly + - If neither local auth nor OIDC is configured, the admin interface runs without authentication - Sessions are secured with auto-generated session keys Security Configuration: @@ -183,9 +186,10 @@ func runAdmin(cmd *Command, args []string) bool { } // Security warnings - if *a.adminPassword == "" { + oidcEnabled := viper.GetBool("admin.oidc.enabled") + if *a.adminPassword == "" && !oidcEnabled { fmt.Println("WARNING: Admin interface is running without authentication!") - fmt.Println(" Set -adminPassword for production use") + fmt.Println(" Set -adminPassword or configure [admin.oidc] in security.toml for production use") } fmt.Printf("Starting SeaweedFS Admin Interface on port %d\n", *a.port) @@ -197,11 +201,17 @@ func runAdmin(cmd *Command, args []string) bool { } else { fmt.Printf("Data Directory: Not specified (configuration will be in-memory only)\n") } - if *a.adminPassword != "" { - fmt.Printf("Authentication: Enabled (admin user: %s)\n", *a.adminUser) + if *a.adminPassword != "" || oidcEnabled { + fmt.Printf("Authentication: Enabled\n") + if *a.adminPassword != "" { + fmt.Printf("Local credentials: Enabled (admin user: %s)\n", *a.adminUser) + } if *a.readOnlyPassword != "" { fmt.Printf("Read-only access: Enabled (read-only user: %s)\n", *a.readOnlyUser) } + if oidcEnabled { + fmt.Printf("OIDC: Enabled (issuer: %s)\n", viper.GetString("admin.oidc.issuer")) + } } else { fmt.Printf("Authentication: Disabled\n") } @@ -292,6 +302,15 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, // Create admin server (plugin is always enabled) adminServer := dash.NewAdminServer(*options.master, nil, dataDir, icebergPort) + oidcAuthConfig := dash.OIDCAuthConfig{} + if err := viper.UnmarshalKey("admin.oidc", &oidcAuthConfig); err != nil { + return fmt.Errorf("failed to parse security.toml [admin.oidc] config: %w", err) + } + oidcAuthService, err := dash.NewOIDCAuthService(oidcAuthConfig) + if err != nil { + return fmt.Errorf("failed to initialize OIDC admin auth: %w", err) + } + // Show discovered filers filers := adminServer.GetAllFilers() if len(filers) > 0 { @@ -314,9 +333,15 @@ func startAdminServer(ctx context.Context, options AdminOptions, enableUI bool, }() // Create handlers and setup routes - authRequired := *options.adminPassword != "" - adminHandlers := handlers.NewAdminHandlers(adminServer, store) - adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword, enableUI) + authConfig := handlers.AuthConfig{ + AdminUser: *options.adminUser, + AdminPassword: *options.adminPassword, + ReadOnlyUser: *options.readOnlyUser, + ReadOnlyPassword: *options.readOnlyPassword, + OIDCAuth: oidcAuthService, + } + adminHandlers := handlers.NewAdminHandlers(adminServer, store, authConfig) + adminHandlers.SetupRoutes(r, enableUI) // Server configuration addr := fmt.Sprintf(":%d", *options.port)