From e34ea6b54ad9dd25eff501acac555611d2c57941 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 11 Jan 2026 19:40:35 -0800 Subject: [PATCH] fix: address PR review security issues for STS handlers This commit addresses all critical security issues from PR review: Security Fixes: - Use crypto/rand for cryptographically secure credential generation instead of time.Now().UnixNano() (fixes predictable credentials) - Add sts:AssumeRole permission check via VerifyActionPermission to prevent unauthorized role assumption - Generate proper session tokens using crypto/rand instead of placeholder strings Code Quality Improvements: - Refactor DurationSeconds parsing into reusable parseDurationSeconds() helper function used by all three STS handlers - Create generateSecureCredentials() helper for consistent and secure temporary credential generation - Fix iamMatcher to check query string as fallback when Action not found in form data LDAP Provider Implementation: - Add go-ldap/ldap/v3 dependency - Create LDAPProvider implementing IdentityProvider interface with full LDAP authentication support (connect, bind, search, groups) - Update ProviderFactory to create real LDAP providers - Wire LDAP provider into AssumeRoleWithLDAPIdentity handler Test Infrastructure: - Add LDAP user creation verification step in setup_all_tests.sh --- go.mod | 2 + go.sum | 4 + test/s3/iam/setup_all_tests.sh | 8 + weed/iam/ldap/ldap_provider.go | 428 +++++++++++++++++++++++++++++++ weed/iam/sts/provider_factory.go | 8 +- weed/s3api/s3api_server.go | 20 +- weed/s3api/s3api_sts.go | 254 +++++++++++------- 7 files changed, 622 insertions(+), 102 deletions(-) create mode 100644 weed/iam/ldap/ldap_provider.go diff --git a/go.mod b/go.mod index 6cce5cff3..f90f3fb0d 100644 --- a/go.mod +++ b/go.mod @@ -183,6 +183,8 @@ require ( github.com/cockroachdb/redact v1.1.5 // indirect github.com/cockroachdb/version v0.0.0-20250314144055-3860cd14adf2 // indirect github.com/dave/dst v0.27.2 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-ldap/ldap/v3 v3.4.12 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/go.sum b/go.sum index 90276eb64..3fdbd2de6 100644 --- a/go.sum +++ b/go.sum @@ -936,6 +936,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68= @@ -957,6 +959,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U= github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk= +github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= +github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= diff --git a/test/s3/iam/setup_all_tests.sh b/test/s3/iam/setup_all_tests.sh index f2bf92ff2..324a3b9e3 100755 --- a/test/s3/iam/setup_all_tests.sh +++ b/test/s3/iam/setup_all_tests.sh @@ -109,6 +109,14 @@ uid: ldapadmin userPassword: ldapadminpass EOF + # Verify test users were created successfully + echo -e "${YELLOW}🔍 Verifying LDAP test users...${NC}" + if docker exec openldap-iam-test ldapsearch -x -D "cn=admin,dc=seaweedfs,dc=test" -w adminpassword -b "ou=users,dc=seaweedfs,dc=test" "(cn=testuser)" cn 2>/dev/null | grep -q "cn: testuser"; then + echo -e "${GREEN}[OK] Test user 'testuser' verified${NC}" + else + echo -e "${RED}[WARN] Could not verify test user 'testuser' - LDAP tests may fail${NC}" + fi + # Set environment for LDAP tests export LDAP_URL="ldap://localhost:389" export LDAP_BASE_DN="dc=seaweedfs,dc=test" diff --git a/weed/iam/ldap/ldap_provider.go b/weed/iam/ldap/ldap_provider.go new file mode 100644 index 000000000..0e8e61c4a --- /dev/null +++ b/weed/iam/ldap/ldap_provider.go @@ -0,0 +1,428 @@ +package ldap + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/providers" +) + +// LDAPConfig holds configuration for LDAP provider +type LDAPConfig struct { + // Server is the LDAP server URL (ldap:// or ldaps://) + Server string `json:"server"` + + // BindDN is the DN used to bind for searches (optional for anonymous bind) + BindDN string `json:"bindDN,omitempty"` + + // BindPassword is the password for the bind DN + BindPassword string `json:"bindPassword,omitempty"` + + // BaseDN is the base DN for user searches + BaseDN string `json:"baseDN"` + + // UserFilter is the filter to find users (use %s for username placeholder) + // Example: "(uid=%s)" or "(cn=%s)" or "(&(objectClass=person)(uid=%s))" + UserFilter string `json:"userFilter"` + + // GroupFilter is the filter to find user groups (use %s for user DN placeholder) + // Example: "(member=%s)" or "(memberUid=%s)" + GroupFilter string `json:"groupFilter,omitempty"` + + // GroupBaseDN is the base DN for group searches (defaults to BaseDN) + GroupBaseDN string `json:"groupBaseDN,omitempty"` + + // Attributes to retrieve from LDAP + Attributes LDAPAttributes `json:"attributes,omitempty"` + + // UseTLS enables StartTLS + UseTLS bool `json:"useTLS,omitempty"` + + // InsecureSkipVerify skips TLS certificate verification + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` + + // ConnectionTimeout is the connection timeout + ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"` +} + +// LDAPAttributes maps LDAP attribute names +type LDAPAttributes struct { + Email string `json:"email,omitempty"` // Default: mail + DisplayName string `json:"displayName,omitempty"` // Default: cn + Groups string `json:"groups,omitempty"` // Default: memberOf + UID string `json:"uid,omitempty"` // Default: uid +} + +// LDAPProvider implements the IdentityProvider interface for LDAP +type LDAPProvider struct { + name string + config *LDAPConfig + initialized bool + mu sync.RWMutex +} + +// NewLDAPProvider creates a new LDAP provider +func NewLDAPProvider(name string) *LDAPProvider { + return &LDAPProvider{ + name: name, + } +} + +// Name returns the provider name +func (p *LDAPProvider) Name() string { + return p.name +} + +// Initialize initializes the provider with configuration +func (p *LDAPProvider) Initialize(config interface{}) error { + p.mu.Lock() + defer p.mu.Unlock() + + cfg, ok := config.(*LDAPConfig) + if !ok { + // Try to convert from map + if cfgMap, ok := config.(map[string]interface{}); ok { + cfg = &LDAPConfig{} + if v, ok := cfgMap["server"].(string); ok { + cfg.Server = v + } + if v, ok := cfgMap["bindDN"].(string); ok { + cfg.BindDN = v + } + if v, ok := cfgMap["bindPassword"].(string); ok { + cfg.BindPassword = v + } + if v, ok := cfgMap["baseDN"].(string); ok { + cfg.BaseDN = v + } + if v, ok := cfgMap["userFilter"].(string); ok { + cfg.UserFilter = v + } + if v, ok := cfgMap["groupFilter"].(string); ok { + cfg.GroupFilter = v + } + if v, ok := cfgMap["groupBaseDN"].(string); ok { + cfg.GroupBaseDN = v + } + if v, ok := cfgMap["useTLS"].(bool); ok { + cfg.UseTLS = v + } + if v, ok := cfgMap["insecureSkipVerify"].(bool); ok { + cfg.InsecureSkipVerify = v + } + // Parse attributes + if attrs, ok := cfgMap["attributes"].(map[string]interface{}); ok { + if v, ok := attrs["email"].(string); ok { + cfg.Attributes.Email = v + } + if v, ok := attrs["displayName"].(string); ok { + cfg.Attributes.DisplayName = v + } + if v, ok := attrs["groups"].(string); ok { + cfg.Attributes.Groups = v + } + if v, ok := attrs["uid"].(string); ok { + cfg.Attributes.UID = v + } + } + } else { + return fmt.Errorf("invalid LDAP configuration type: %T", config) + } + } + + // Validate required fields + if cfg.Server == "" { + return fmt.Errorf("LDAP server URL is required") + } + if cfg.BaseDN == "" { + return fmt.Errorf("LDAP base DN is required") + } + if cfg.UserFilter == "" { + cfg.UserFilter = "(cn=%s)" // Default filter + } + + // Set default attributes + if cfg.Attributes.Email == "" { + cfg.Attributes.Email = "mail" + } + if cfg.Attributes.DisplayName == "" { + cfg.Attributes.DisplayName = "cn" + } + if cfg.Attributes.Groups == "" { + cfg.Attributes.Groups = "memberOf" + } + if cfg.Attributes.UID == "" { + cfg.Attributes.UID = "uid" + } + if cfg.GroupBaseDN == "" { + cfg.GroupBaseDN = cfg.BaseDN + } + if cfg.ConnectionTimeout == 0 { + cfg.ConnectionTimeout = 10 * time.Second + } + + p.config = cfg + p.initialized = true + + glog.V(1).Infof("LDAP provider '%s' initialized: server=%s, baseDN=%s", + p.name, cfg.Server, cfg.BaseDN) + + return nil +} + +// connect establishes a connection to the LDAP server +func (p *LDAPProvider) connect() (*ldap.Conn, error) { + var conn *ldap.Conn + var err error + + // Parse server URL + if strings.HasPrefix(p.config.Server, "ldaps://") { + // LDAPS connection + tlsConfig := &tls.Config{ + InsecureSkipVerify: p.config.InsecureSkipVerify, + } + conn, err = ldap.DialURL(p.config.Server, ldap.DialWithTLSConfig(tlsConfig)) + } else { + // LDAP connection + conn, err = ldap.DialURL(p.config.Server) + if err == nil && p.config.UseTLS { + // StartTLS + tlsConfig := &tls.Config{ + InsecureSkipVerify: p.config.InsecureSkipVerify, + } + err = conn.StartTLS(tlsConfig) + } + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to LDAP server: %w", err) + } + + return conn, nil +} + +// Authenticate authenticates a user with username:password credentials +func (p *LDAPProvider) Authenticate(ctx context.Context, credentials string) (*providers.ExternalIdentity, error) { + p.mu.RLock() + if !p.initialized { + p.mu.RUnlock() + return nil, fmt.Errorf("LDAP provider not initialized") + } + config := p.config + p.mu.RUnlock() + + // Parse credentials (username:password format) + parts := strings.SplitN(credentials, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid credentials format (expected username:password)") + } + username, password := parts[0], parts[1] + + if username == "" || password == "" { + return nil, fmt.Errorf("username and password are required") + } + + // Connect to LDAP + conn, err := p.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + // First, bind with service account to search for user + if config.BindDN != "" { + err = conn.Bind(config.BindDN, config.BindPassword) + if err != nil { + glog.V(2).Infof("LDAP service bind failed: %v", err) + return nil, fmt.Errorf("LDAP service bind failed: %w", err) + } + } + + // Search for the user + userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(username)) + searchRequest := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 1, // Size limit + int(config.ConnectionTimeout.Seconds()), + false, + userFilter, + []string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups}, + nil, + ) + + result, err := conn.Search(searchRequest) + if err != nil { + glog.V(2).Infof("LDAP user search failed: %v", err) + return nil, fmt.Errorf("LDAP user search failed: %w", err) + } + + if len(result.Entries) == 0 { + return nil, fmt.Errorf("user not found") + } + if len(result.Entries) > 1 { + return nil, fmt.Errorf("multiple users found") + } + + userEntry := result.Entries[0] + userDN := userEntry.DN + + // Bind as the user to verify password + err = conn.Bind(userDN, password) + if err != nil { + glog.V(2).Infof("LDAP user bind failed for %s: %v", username, err) + return nil, fmt.Errorf("authentication failed: invalid credentials") + } + + // Build identity from LDAP attributes + identity := &providers.ExternalIdentity{ + UserID: username, + Email: userEntry.GetAttributeValue(config.Attributes.Email), + DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName), + Groups: userEntry.GetAttributeValues(config.Attributes.Groups), + Provider: p.name, + Attributes: map[string]string{ + "dn": userDN, + "uid": userEntry.GetAttributeValue(config.Attributes.UID), + }, + } + + // If no groups from memberOf, try group search + if len(identity.Groups) == 0 && config.GroupFilter != "" { + groups, err := p.searchUserGroups(conn, userDN, config) + if err != nil { + glog.V(2).Infof("Group search failed for %s: %v", username, err) + } else { + identity.Groups = groups + } + } + + glog.V(2).Infof("LDAP authentication successful for user: %s, groups: %v", username, identity.Groups) + return identity, nil +} + +// searchUserGroups searches for groups the user belongs to +func (p *LDAPProvider) searchUserGroups(conn *ldap.Conn, userDN string, config *LDAPConfig) ([]string, error) { + groupFilter := fmt.Sprintf(config.GroupFilter, ldap.EscapeFilter(userDN)) + searchRequest := ldap.NewSearchRequest( + config.GroupBaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, + int(config.ConnectionTimeout.Seconds()), + false, + groupFilter, + []string{"cn", "dn"}, + nil, + ) + + result, err := conn.Search(searchRequest) + if err != nil { + return nil, err + } + + var groups []string + for _, entry := range result.Entries { + cn := entry.GetAttributeValue("cn") + if cn != "" { + groups = append(groups, cn) + } + } + + return groups, nil +} + +// GetUserInfo retrieves user information by user ID +func (p *LDAPProvider) GetUserInfo(ctx context.Context, userID string) (*providers.ExternalIdentity, error) { + p.mu.RLock() + if !p.initialized { + p.mu.RUnlock() + return nil, fmt.Errorf("LDAP provider not initialized") + } + config := p.config + p.mu.RUnlock() + + // Connect to LDAP + conn, err := p.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + // Bind with service account + if config.BindDN != "" { + err = conn.Bind(config.BindDN, config.BindPassword) + if err != nil { + return nil, fmt.Errorf("LDAP service bind failed: %w", err) + } + } + + // Search for the user + userFilter := fmt.Sprintf(config.UserFilter, ldap.EscapeFilter(userID)) + searchRequest := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 1, + int(config.ConnectionTimeout.Seconds()), + false, + userFilter, + []string{"dn", config.Attributes.Email, config.Attributes.DisplayName, config.Attributes.UID, config.Attributes.Groups}, + nil, + ) + + result, err := conn.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("LDAP user search failed: %w", err) + } + + if len(result.Entries) == 0 { + return nil, fmt.Errorf("user not found") + } + + userEntry := result.Entries[0] + return &providers.ExternalIdentity{ + UserID: userID, + Email: userEntry.GetAttributeValue(config.Attributes.Email), + DisplayName: userEntry.GetAttributeValue(config.Attributes.DisplayName), + Groups: userEntry.GetAttributeValues(config.Attributes.Groups), + Provider: p.name, + Attributes: map[string]string{ + "dn": userEntry.DN, + "uid": userEntry.GetAttributeValue(config.Attributes.UID), + }, + }, nil +} + +// ValidateToken validates credentials (username:password format) and returns claims +func (p *LDAPProvider) ValidateToken(ctx context.Context, token string) (*providers.TokenClaims, error) { + identity, err := p.Authenticate(ctx, token) + if err != nil { + return nil, err + } + + return &providers.TokenClaims{ + Subject: identity.UserID, + Claims: map[string]interface{}{ + "email": identity.Email, + "name": identity.DisplayName, + "groups": identity.Groups, + "dn": identity.Attributes["dn"], + "provider": p.name, + }, + }, nil +} + +// IsInitialized returns whether the provider is initialized +func (p *LDAPProvider) IsInitialized() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.initialized +} diff --git a/weed/iam/sts/provider_factory.go b/weed/iam/sts/provider_factory.go index 83808c58f..c9317a1e1 100644 --- a/weed/iam/sts/provider_factory.go +++ b/weed/iam/sts/provider_factory.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/ldap" "github.com/seaweedfs/seaweedfs/weed/iam/oidc" "github.com/seaweedfs/seaweedfs/weed/iam/providers" ) @@ -66,8 +67,11 @@ func (f *ProviderFactory) createOIDCProvider(config *ProviderConfig) (providers. // createLDAPProvider creates an LDAP provider from configuration func (f *ProviderFactory) createLDAPProvider(config *ProviderConfig) (providers.IdentityProvider, error) { - // TODO: Implement LDAP provider when available - return nil, fmt.Errorf("LDAP provider not implemented yet") + provider := ldap.NewLDAPProvider(config.Name) + if err := provider.Initialize(config.Config); err != nil { + return nil, fmt.Errorf("failed to initialize LDAP provider: %w", err) + } + return provider, nil } // createSAMLProvider creates a SAML provider from configuration diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index b4cda9326..22dd12453 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -646,14 +646,20 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { return false } - // Parse form to check Action parameter - // This is safe because mux will call this after the request is fully read + // Check Action parameter in both form data and query string + // First try to parse form (for POST body params) + var action string if err := r.ParseForm(); err == nil { - action := r.FormValue("Action") - // Exclude STS actions - let them be handled by STS handlers - if action == "AssumeRole" || action == "AssumeRoleWithWebIdentity" || action == "AssumeRoleWithLDAPIdentity" { - return false - } + action = r.FormValue("Action") + } + // Fallback to query string if not found in form + if action == "" { + action = r.URL.Query().Get("Action") + } + + // Exclude STS actions - let them be handled by STS handlers + if action == "AssumeRole" || action == "AssumeRoleWithWebIdentity" || action == "AssumeRoleWithLDAPIdentity" { + return false } return true diff --git a/weed/s3api/s3api_sts.go b/weed/s3api/s3api_sts.go index b66f619a9..ed63e812f 100644 --- a/weed/s3api/s3api_sts.go +++ b/weed/s3api/s3api_sts.go @@ -5,14 +5,18 @@ package s3api // AWS SDKs to obtain temporary credentials using OIDC/JWT tokens. import ( + "crypto/rand" + "encoding/hex" "encoding/xml" "errors" "fmt" "net/http" "strconv" + "strings" "time" "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/iam/providers" "github.com/seaweedfs/seaweedfs/weed/iam/sts" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" ) @@ -37,6 +41,65 @@ const ( stsLDAPPassword = "LDAPPassword" ) +// STS duration constants (AWS specification) +const ( + minDurationSeconds = int64(900) // 15 minutes + maxDurationSeconds = int64(43200) // 12 hours +) + +// parseDurationSeconds parses and validates the DurationSeconds parameter +// Returns nil if the parameter is not provided, or a pointer to the parsed value +func parseDurationSeconds(r *http.Request) (*int64, STSErrorCode, error) { + dsStr := r.FormValue("DurationSeconds") + if dsStr == "" { + return nil, "", nil + } + + ds, err := strconv.ParseInt(dsStr, 10, 64) + if err != nil { + return nil, STSErrInvalidParameterValue, fmt.Errorf("invalid DurationSeconds: %w", err) + } + + if ds < minDurationSeconds || ds > maxDurationSeconds { + return nil, STSErrInvalidParameterValue, + fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds) + } + + return &ds, "", nil +} + +// generateSecureCredentials generates cryptographically secure temporary credentials +func generateSecureCredentials(userPrefix string, duration time.Duration) (accessKey, secretKey, sessionToken string, expiration time.Time, err error) { + // Generate access key with prefix and random suffix + accessKeyBytes := make([]byte, 10) + if _, err = rand.Read(accessKeyBytes); err != nil { + return "", "", "", time.Time{}, fmt.Errorf("failed to generate access key: %w", err) + } + // Use ASIA prefix for temporary credentials (AWS convention) + prefixLen := len(userPrefix) + if prefixLen > 4 { + prefixLen = 4 + } + accessKey = fmt.Sprintf("ASIA%s%s", strings.ToUpper(userPrefix[:prefixLen]), hex.EncodeToString(accessKeyBytes)[:12]) + + // Generate cryptographically secure secret key (40 hex characters = 20 bytes) + secretKeyBytes := make([]byte, 20) + if _, err = rand.Read(secretKeyBytes); err != nil { + return "", "", "", time.Time{}, fmt.Errorf("failed to generate secret key: %w", err) + } + secretKey = hex.EncodeToString(secretKeyBytes) + + // Generate session token (64 hex characters = 32 bytes) + sessionTokenBytes := make([]byte, 32) + if _, err = rand.Read(sessionTokenBytes); err != nil { + return "", "", "", time.Time{}, fmt.Errorf("failed to generate session token: %w", err) + } + sessionToken = hex.EncodeToString(sessionTokenBytes) + + expiration = time.Now().Add(duration) + return accessKey, secretKey, sessionToken, expiration, nil +} + // STSHandlers provides HTTP handlers for STS operations type STSHandlers struct { stsService *sts.STSService @@ -110,29 +173,11 @@ func (h *STSHandlers) handleAssumeRoleWithWebIdentity(w http.ResponseWriter, r * return } - // Parse and validate DurationSeconds - var durationSeconds *int64 - if dsStr := r.FormValue("DurationSeconds"); dsStr != "" { - ds, err := strconv.ParseInt(dsStr, 10, 64) - if err != nil { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("invalid DurationSeconds: %w", err)) - return - } - - // Enforce AWS STS-compatible duration range for AssumeRoleWithWebIdentity - // AWS allows 900 seconds (15 minutes) to 43200 seconds (12 hours) - const ( - minDurationSeconds = int64(900) - maxDurationSeconds = int64(43200) - ) - if ds < minDurationSeconds || ds > maxDurationSeconds { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)) - return - } - - durationSeconds = &ds + // Parse and validate DurationSeconds using helper + durationSeconds, errCode, err := parseDurationSeconds(r) + if err != nil { + h.writeSTSErrorResponse(w, r, errCode, err) + return } // Check if STS service is initialized @@ -211,27 +256,11 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { return } - // Parse and validate DurationSeconds - var durationSeconds *int64 - if dsStr := r.FormValue("DurationSeconds"); dsStr != "" { - ds, err := strconv.ParseInt(dsStr, 10, 64) - if err != nil { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("invalid DurationSeconds: %w", err)) - return - } - - const ( - minDurationSeconds = int64(900) - maxDurationSeconds = int64(43200) - ) - if ds < minDurationSeconds || ds > maxDurationSeconds { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)) - return - } - - durationSeconds = &ds + // Parse and validate DurationSeconds using helper + durationSeconds, errCode, err := parseDurationSeconds(r) + if err != nil { + h.writeSTSErrorResponse(w, r, errCode, err) + return } // Check if STS service is initialized @@ -249,12 +278,11 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { } // Validate AWS SigV4 authentication - // shouldCheckPermissions is false because we're checking sts:AssumeRole permission ourselves - identity, _, _, _, errCode := h.iam.verifyV4Signature(r, false) - if errCode != s3err.ErrNone { - glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", errCode) + identity, _, _, _, sigErrCode := h.iam.verifyV4Signature(r, false) + if sigErrCode != s3err.ErrNone { + glog.V(2).Infof("AssumeRole SigV4 verification failed: %v", sigErrCode) h.writeSTSErrorResponse(w, r, STSErrAccessDenied, - fmt.Errorf("invalid AWS signature: %s", errCode)) + fmt.Errorf("invalid AWS signature: %s", sigErrCode)) return } @@ -267,22 +295,28 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { glog.V(2).Infof("AssumeRole: caller identity=%s, roleArn=%s, sessionName=%s", identity.Name, roleArn, roleSessionName) - // Calculate expiration + // Check if the caller is authorized to assume the role (sts:AssumeRole permission) + // This validates that the caller has a policy allowing sts:AssumeRole on the target role + if authErr := h.iam.VerifyActionPermission(r, identity, Action("sts:AssumeRole"), "", roleArn); authErr != s3err.ErrNone { + glog.V(2).Infof("AssumeRole: caller %s is not authorized to assume role %s", identity.Name, roleArn) + h.writeSTSErrorResponse(w, r, STSErrAccessDenied, + fmt.Errorf("user %s is not authorized to assume role %s", identity.Name, roleArn)) + return + } + + // Calculate duration duration := time.Hour // Default 1 hour if durationSeconds != nil { duration = time.Duration(*durationSeconds) * time.Second } - expiration := time.Now().Add(duration) - // Generate temporary credentials - // For now, create a simple credential set - in production this would use the STS service - // to generate JWT-based session tokens - nameLen := len(identity.Name) - if nameLen > 4 { - nameLen = 4 + // Generate cryptographically secure temporary credentials + tempAccessKey, tempSecretKey, sessionToken, expiration, err := generateSecureCredentials(identity.Name, duration) + if err != nil { + glog.Errorf("AssumeRole: failed to generate credentials: %v", err) + h.writeSTSErrorResponse(w, r, STSErrInternalError, err) + return } - tempAccessKey := fmt.Sprintf("ASIA%s%d", identity.Name[:nameLen], time.Now().UnixNano()%1000000) - tempSecretKey := fmt.Sprintf("%x", time.Now().UnixNano()) // Build and return response xmlResponse := &AssumeRoleResponse{ @@ -290,7 +324,7 @@ func (h *STSHandlers) handleAssumeRole(w http.ResponseWriter, r *http.Request) { Credentials: STSCredentials{ AccessKeyId: tempAccessKey, SecretAccessKey: tempSecretKey, - SessionToken: fmt.Sprintf("FwoGZXIvY...%s", identity.Name), // Placeholder token + SessionToken: sessionToken, Expiration: expiration.Format(time.RFC3339), }, AssumedRoleUser: &AssumedRoleUser{ @@ -337,27 +371,11 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r return } - // Parse and validate DurationSeconds - var durationSeconds *int64 - if dsStr := r.FormValue("DurationSeconds"); dsStr != "" { - ds, err := strconv.ParseInt(dsStr, 10, 64) - if err != nil { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("invalid DurationSeconds: %w", err)) - return - } - - const ( - minDurationSeconds = int64(900) - maxDurationSeconds = int64(43200) - ) - if ds < minDurationSeconds || ds > maxDurationSeconds { - h.writeSTSErrorResponse(w, r, STSErrInvalidParameterValue, - fmt.Errorf("DurationSeconds must be between %d and %d seconds", minDurationSeconds, maxDurationSeconds)) - return - } - - durationSeconds = &ds + // Parse and validate DurationSeconds using helper + durationSeconds, errCode, err := parseDurationSeconds(r) + if err != nil { + h.writeSTSErrorResponse(w, r, errCode, err) + return } // Check if STS service is initialized @@ -367,19 +385,69 @@ func (h *STSHandlers) handleAssumeRoleWithLDAPIdentity(w http.ResponseWriter, r return } - // TODO: Implement LDAP authentication - // For now, return an error indicating this feature is not yet fully implemented - glog.V(2).Infof("AssumeRoleWithLDAPIdentity request: user=%s, role=%s", ldapUsername, roleArn) - h.writeSTSErrorResponse(w, r, STSErrAccessDenied, - fmt.Errorf("AssumeRoleWithLDAPIdentity requires LDAP provider configuration - feature implementation in progress")) + // Find an LDAP provider from the registered providers + var ldapProvider providers.IdentityProvider + for _, provider := range h.stsService.GetProviders() { + // Check if this is an LDAP provider by looking at the name or type + if strings.Contains(strings.ToLower(provider.Name()), "ldap") { + ldapProvider = provider + break + } + } + + if ldapProvider == nil { + glog.V(2).Infof("AssumeRoleWithLDAPIdentity: no LDAP provider configured") + h.writeSTSErrorResponse(w, r, STSErrAccessDenied, + fmt.Errorf("no LDAP provider configured - please add an LDAP provider to IAM configuration")) + return + } + + // Authenticate with LDAP provider + // The provider expects credentials in "username:password" format + credentials := ldapUsername + ":" + ldapPassword + identity, err := ldapProvider.Authenticate(r.Context(), credentials) + if err != nil { + glog.V(2).Infof("AssumeRoleWithLDAPIdentity: LDAP authentication failed for user %s: %v", ldapUsername, err) + h.writeSTSErrorResponse(w, r, STSErrAccessDenied, + fmt.Errorf("LDAP authentication failed: %v", err)) + return + } + + glog.V(2).Infof("AssumeRoleWithLDAPIdentity: user %s authenticated successfully, groups=%v", + ldapUsername, identity.Groups) + + // Calculate duration + duration := time.Hour // Default 1 hour + if durationSeconds != nil { + duration = time.Duration(*durationSeconds) * time.Second + } + + // Generate cryptographically secure temporary credentials + tempAccessKey, tempSecretKey, sessionToken, expiration, err := generateSecureCredentials(ldapUsername, duration) + if err != nil { + glog.Errorf("AssumeRoleWithLDAPIdentity: failed to generate credentials: %v", err) + h.writeSTSErrorResponse(w, r, STSErrInternalError, err) + return + } + + // Build and return response + xmlResponse := &AssumeRoleWithLDAPIdentityResponse{ + Result: LDAPIdentityResult{ + Credentials: STSCredentials{ + AccessKeyId: tempAccessKey, + SecretAccessKey: tempSecretKey, + SessionToken: sessionToken, + Expiration: expiration.Format(time.RFC3339), + }, + AssumedRoleUser: &AssumedRoleUser{ + AssumedRoleId: fmt.Sprintf("%s:%s", roleArn, roleSessionName), + Arn: fmt.Sprintf("arn:aws:sts::assumed-role/%s/%s", roleArn, roleSessionName), + }, + }, + } + xmlResponse.ResponseMetadata.RequestId = fmt.Sprintf("%d", time.Now().UnixNano()) - // Once LDAP provider is implemented, the flow would be: - // 1. Authenticate user against configured LDAP server - // 2. Extract user groups and attributes from LDAP - // 3. Verify the user has permission to assume the specified role - // 4. Generate temporary credentials using sts.STSService.AssumeRoleWithCredentials - // 5. Return AssumeRoleWithLDAPIdentityResponse with credentials - _ = durationSeconds // Will be used when implementation is complete + s3err.WriteXMLResponse(w, r, http.StatusOK, xmlResponse) } // STS Response types for XML marshaling