8 changed files with 822 additions and 23 deletions
-
9weed/admin/dash/csrf.go
-
544weed/admin/dash/oidc_auth.go
-
67weed/admin/dash/oidc_auth_test.go
-
16weed/admin/handlers/admin_handlers.go
-
23weed/admin/handlers/admin_handlers_routes_test.go
-
23weed/admin/handlers/auth_config.go
-
122weed/admin/handlers/auth_handlers.go
-
41weed/command/admin.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)) |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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() |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue