Browse Source

feat(admin): add OIDC authorization-code login backend

codex/admin-oidc-auth-ui
Copilot 1 week ago
parent
commit
2d6eff5abc
  1. 9
      weed/admin/dash/csrf.go
  2. 544
      weed/admin/dash/oidc_auth.go
  3. 67
      weed/admin/dash/oidc_auth_test.go
  4. 16
      weed/admin/handlers/admin_handlers.go
  5. 23
      weed/admin/handlers/admin_handlers_routes_test.go
  6. 23
      weed/admin/handlers/auth_config.go
  7. 122
      weed/admin/handlers/auth_handlers.go
  8. 41
      weed/command/admin.go

9
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

544
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))
}

67
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)
}
}

16
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()

23
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},

23
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()
}

122
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
}

41
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)

Loading…
Cancel
Save