You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							383 lines
						
					
					
						
							13 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							383 lines
						
					
					
						
							13 KiB
						
					
					
				| package s3api | |
| 
 | |
| import ( | |
| 	"context" | |
| 	"crypto/sha256" | |
| 	"encoding/hex" | |
| 	"fmt" | |
| 	"net/http" | |
| 	"net/url" | |
| 	"strconv" | |
| 	"strings" | |
| 	"time" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/glog" | |
| 	"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" | |
| 	"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" | |
| ) | |
| 
 | |
| // S3PresignedURLManager handles IAM integration for presigned URLs | |
| type S3PresignedURLManager struct { | |
| 	s3iam *S3IAMIntegration | |
| } | |
| 
 | |
| // NewS3PresignedURLManager creates a new presigned URL manager with IAM integration | |
| func NewS3PresignedURLManager(s3iam *S3IAMIntegration) *S3PresignedURLManager { | |
| 	return &S3PresignedURLManager{ | |
| 		s3iam: s3iam, | |
| 	} | |
| } | |
| 
 | |
| // PresignedURLRequest represents a request to generate a presigned URL | |
| type PresignedURLRequest struct { | |
| 	Method       string            `json:"method"`        // HTTP method (GET, PUT, POST, DELETE) | |
| 	Bucket       string            `json:"bucket"`        // S3 bucket name | |
| 	ObjectKey    string            `json:"object_key"`    // S3 object key | |
| 	Expiration   time.Duration     `json:"expiration"`    // URL expiration duration | |
| 	SessionToken string            `json:"session_token"` // JWT session token for IAM | |
| 	Headers      map[string]string `json:"headers"`       // Additional headers to sign | |
| 	QueryParams  map[string]string `json:"query_params"`  // Additional query parameters | |
| } | |
| 
 | |
| // PresignedURLResponse represents the generated presigned URL | |
| type PresignedURLResponse struct { | |
| 	URL            string            `json:"url"`             // The presigned URL | |
| 	Method         string            `json:"method"`          // HTTP method | |
| 	Headers        map[string]string `json:"headers"`         // Required headers | |
| 	ExpiresAt      time.Time         `json:"expires_at"`      // URL expiration time | |
| 	SignedHeaders  []string          `json:"signed_headers"`  // List of signed headers | |
| 	CanonicalQuery string            `json:"canonical_query"` // Canonical query string | |
| } | |
| 
 | |
| // ValidatePresignedURLWithIAM validates a presigned URL request using IAM policies | |
| func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request, identity *Identity) s3err.ErrorCode { | |
| 	if iam.iamIntegration == nil { | |
| 		// Fall back to standard validation | |
| 		return s3err.ErrNone | |
| 	} | |
| 
 | |
| 	// Extract bucket and object from request | |
| 	bucket, object := s3_constants.GetBucketAndObject(r) | |
| 
 | |
| 	// Determine the S3 action from HTTP method and path | |
| 	action := determineS3ActionFromRequest(r, bucket, object) | |
| 
 | |
| 	// Check if the user has permission for this action | |
| 	ctx := r.Context() | |
| 	sessionToken := extractSessionTokenFromPresignedURL(r) | |
| 	if sessionToken == "" { | |
| 		// No session token in presigned URL - use standard auth | |
| 		return s3err.ErrNone | |
| 	} | |
| 
 | |
| 	// Parse JWT token to extract role and session information | |
| 	tokenClaims, err := parseJWTToken(sessionToken) | |
| 	if err != nil { | |
| 		glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err) | |
| 		return s3err.ErrAccessDenied | |
| 	} | |
| 
 | |
| 	// Extract role information from token claims | |
| 	roleName, ok := tokenClaims["role"].(string) | |
| 	if !ok || roleName == "" { | |
| 		glog.V(3).Info("No role found in JWT token for presigned URL") | |
| 		return s3err.ErrAccessDenied | |
| 	} | |
| 
 | |
| 	sessionName, ok := tokenClaims["snam"].(string) | |
| 	if !ok || sessionName == "" { | |
| 		sessionName = "presigned-session" // Default fallback | |
| 	} | |
| 
 | |
| 	// Use the principal ARN directly from token claims, or build it if not available | |
| 	principalArn, ok := tokenClaims["principal"].(string) | |
| 	if !ok || principalArn == "" { | |
| 		// Fallback: extract role name from role ARN and build principal ARN | |
| 		roleNameOnly := roleName | |
| 		if strings.Contains(roleName, "/") { | |
| 			parts := strings.Split(roleName, "/") | |
| 			roleNameOnly = parts[len(parts)-1] | |
| 		} | |
| 		principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName) | |
| 	} | |
| 
 | |
| 	// Create IAM identity for authorization using extracted information | |
| 	iamIdentity := &IAMIdentity{ | |
| 		Name:         identity.Name, | |
| 		Principal:    principalArn, | |
| 		SessionToken: sessionToken, | |
| 		Account:      identity.Account, | |
| 	} | |
| 
 | |
| 	// Authorize using IAM | |
| 	errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r) | |
| 	if errCode != s3err.ErrNone { | |
| 		glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s", | |
| 			iamIdentity.Principal, action, bucket, object) | |
| 		return errCode | |
| 	} | |
| 
 | |
| 	glog.V(3).Infof("IAM authorization succeeded for presigned URL: principal=%s action=%s bucket=%s object=%s", | |
| 		iamIdentity.Principal, action, bucket, object) | |
| 	return s3err.ErrNone | |
| } | |
| 
 | |
| // GeneratePresignedURLWithIAM generates a presigned URL with IAM policy validation | |
| func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context, req *PresignedURLRequest, baseURL string) (*PresignedURLResponse, error) { | |
| 	if pm.s3iam == nil || !pm.s3iam.enabled { | |
| 		return nil, fmt.Errorf("IAM integration not enabled") | |
| 	} | |
| 
 | |
| 	// Validate session token and get identity | |
| 	// Use a proper ARN format for the principal | |
| 	principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/presigned-session") | |
| 	iamIdentity := &IAMIdentity{ | |
| 		SessionToken: req.SessionToken, | |
| 		Principal:    principalArn, | |
| 		Name:         "presigned-user", | |
| 		Account:      &AccountAdmin, | |
| 	} | |
| 
 | |
| 	// Determine S3 action from method | |
| 	action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey) | |
| 
 | |
| 	// Check IAM permissions before generating URL | |
| 	authRequest := &http.Request{ | |
| 		Method: req.Method, | |
| 		URL:    &url.URL{Path: "/" + req.Bucket + "/" + req.ObjectKey}, | |
| 		Header: make(http.Header), | |
| 	} | |
| 	authRequest.Header.Set("Authorization", "Bearer "+req.SessionToken) | |
| 	authRequest = authRequest.WithContext(ctx) | |
| 
 | |
| 	errCode := pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest) | |
| 	if errCode != s3err.ErrNone { | |
| 		return nil, fmt.Errorf("IAM authorization failed: user does not have permission for action %s on resource %s/%s", action, req.Bucket, req.ObjectKey) | |
| 	} | |
| 
 | |
| 	// Generate presigned URL with validated permissions | |
| 	return pm.generatePresignedURL(req, baseURL, iamIdentity) | |
| } | |
| 
 | |
| // generatePresignedURL creates the actual presigned URL | |
| func (pm *S3PresignedURLManager) generatePresignedURL(req *PresignedURLRequest, baseURL string, identity *IAMIdentity) (*PresignedURLResponse, error) { | |
| 	// Calculate expiration time | |
| 	expiresAt := time.Now().Add(req.Expiration) | |
| 
 | |
| 	// Build the base URL | |
| 	urlPath := "/" + req.Bucket | |
| 	if req.ObjectKey != "" { | |
| 		urlPath += "/" + req.ObjectKey | |
| 	} | |
| 
 | |
| 	// Create query parameters for AWS signature v4 | |
| 	queryParams := make(map[string]string) | |
| 	for k, v := range req.QueryParams { | |
| 		queryParams[k] = v | |
| 	} | |
| 
 | |
| 	// Add AWS signature v4 parameters | |
| 	queryParams["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256" | |
| 	queryParams["X-Amz-Credential"] = fmt.Sprintf("seaweedfs/%s/us-east-1/s3/aws4_request", expiresAt.Format("20060102")) | |
| 	queryParams["X-Amz-Date"] = expiresAt.Format("20060102T150405Z") | |
| 	queryParams["X-Amz-Expires"] = strconv.Itoa(int(req.Expiration.Seconds())) | |
| 	queryParams["X-Amz-SignedHeaders"] = "host" | |
| 
 | |
| 	// Add session token if available | |
| 	if identity.SessionToken != "" { | |
| 		queryParams["X-Amz-Security-Token"] = identity.SessionToken | |
| 	} | |
| 
 | |
| 	// Build canonical query string | |
| 	canonicalQuery := buildCanonicalQuery(queryParams) | |
| 
 | |
| 	// For now, we'll create a mock signature | |
| 	// In production, this would use proper AWS signature v4 signing | |
| 	mockSignature := generateMockSignature(req.Method, urlPath, canonicalQuery, identity.SessionToken) | |
| 	queryParams["X-Amz-Signature"] = mockSignature | |
| 
 | |
| 	// Build final URL | |
| 	finalQuery := buildCanonicalQuery(queryParams) | |
| 	fullURL := baseURL + urlPath + "?" + finalQuery | |
| 
 | |
| 	// Prepare response | |
| 	headers := make(map[string]string) | |
| 	for k, v := range req.Headers { | |
| 		headers[k] = v | |
| 	} | |
| 
 | |
| 	return &PresignedURLResponse{ | |
| 		URL:            fullURL, | |
| 		Method:         req.Method, | |
| 		Headers:        headers, | |
| 		ExpiresAt:      expiresAt, | |
| 		SignedHeaders:  []string{"host"}, | |
| 		CanonicalQuery: canonicalQuery, | |
| 	}, nil | |
| } | |
| 
 | |
| // Helper functions | |
|  | |
| // determineS3ActionFromRequest determines the S3 action based on HTTP request | |
| func determineS3ActionFromRequest(r *http.Request, bucket, object string) Action { | |
| 	return determineS3ActionFromMethodAndPath(r.Method, bucket, object) | |
| } | |
| 
 | |
| // determineS3ActionFromMethodAndPath determines the S3 action based on method and path | |
| func determineS3ActionFromMethodAndPath(method, bucket, object string) Action { | |
| 	switch method { | |
| 	case "GET": | |
| 		if object == "" { | |
| 			return s3_constants.ACTION_LIST // ListBucket | |
| 		} else { | |
| 			return s3_constants.ACTION_READ // GetObject | |
| 		} | |
| 	case "PUT", "POST": | |
| 		return s3_constants.ACTION_WRITE // PutObject | |
| 	case "DELETE": | |
| 		if object == "" { | |
| 			return s3_constants.ACTION_DELETE_BUCKET // DeleteBucket | |
| 		} else { | |
| 			return s3_constants.ACTION_WRITE // DeleteObject (uses WRITE action) | |
| 		} | |
| 	case "HEAD": | |
| 		if object == "" { | |
| 			return s3_constants.ACTION_LIST // HeadBucket | |
| 		} else { | |
| 			return s3_constants.ACTION_READ // HeadObject | |
| 		} | |
| 	default: | |
| 		return s3_constants.ACTION_READ // Default to read | |
| 	} | |
| } | |
| 
 | |
| // extractSessionTokenFromPresignedURL extracts session token from presigned URL query parameters | |
| func extractSessionTokenFromPresignedURL(r *http.Request) string { | |
| 	// Check for X-Amz-Security-Token in query parameters | |
| 	if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" { | |
| 		return token | |
| 	} | |
| 
 | |
| 	// Check for session token in other possible locations | |
| 	if token := r.URL.Query().Get("SessionToken"); token != "" { | |
| 		return token | |
| 	} | |
| 
 | |
| 	return "" | |
| } | |
| 
 | |
| // buildCanonicalQuery builds a canonical query string for AWS signature | |
| func buildCanonicalQuery(params map[string]string) string { | |
| 	var keys []string | |
| 	for k := range params { | |
| 		keys = append(keys, k) | |
| 	} | |
| 
 | |
| 	// Sort keys for canonical order | |
| 	for i := 0; i < len(keys); i++ { | |
| 		for j := i + 1; j < len(keys); j++ { | |
| 			if keys[i] > keys[j] { | |
| 				keys[i], keys[j] = keys[j], keys[i] | |
| 			} | |
| 		} | |
| 	} | |
| 
 | |
| 	var parts []string | |
| 	for _, k := range keys { | |
| 		parts = append(parts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(params[k]))) | |
| 	} | |
| 
 | |
| 	return strings.Join(parts, "&") | |
| } | |
| 
 | |
| // generateMockSignature generates a mock signature for testing purposes | |
| func generateMockSignature(method, path, query, sessionToken string) string { | |
| 	// This is a simplified signature for demonstration | |
| 	// In production, use proper AWS signature v4 calculation | |
| 	data := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, query, sessionToken) | |
| 	hash := sha256.Sum256([]byte(data)) | |
| 	return hex.EncodeToString(hash[:])[:16] // Truncate for readability | |
| } | |
| 
 | |
| // ValidatePresignedURLExpiration validates that a presigned URL hasn't expired | |
| func ValidatePresignedURLExpiration(r *http.Request) error { | |
| 	query := r.URL.Query() | |
| 
 | |
| 	// Get X-Amz-Date and X-Amz-Expires | |
| 	dateStr := query.Get("X-Amz-Date") | |
| 	expiresStr := query.Get("X-Amz-Expires") | |
| 
 | |
| 	if dateStr == "" || expiresStr == "" { | |
| 		return fmt.Errorf("missing required presigned URL parameters") | |
| 	} | |
| 
 | |
| 	// Parse date (always in UTC) | |
| 	signedDate, err := time.Parse("20060102T150405Z", dateStr) | |
| 	if err != nil { | |
| 		return fmt.Errorf("invalid X-Amz-Date format: %v", err) | |
| 	} | |
| 
 | |
| 	// Parse expires | |
| 	expires, err := strconv.Atoi(expiresStr) | |
| 	if err != nil { | |
| 		return fmt.Errorf("invalid X-Amz-Expires format: %v", err) | |
| 	} | |
| 
 | |
| 	// Check expiration - compare in UTC | |
| 	expirationTime := signedDate.Add(time.Duration(expires) * time.Second) | |
| 	now := time.Now().UTC() | |
| 	if now.After(expirationTime) { | |
| 		return fmt.Errorf("presigned URL has expired") | |
| 	} | |
| 
 | |
| 	return nil | |
| } | |
| 
 | |
| // PresignedURLSecurityPolicy represents security constraints for presigned URL generation | |
| type PresignedURLSecurityPolicy struct { | |
| 	MaxExpirationDuration time.Duration `json:"max_expiration_duration"` // Maximum allowed expiration | |
| 	AllowedMethods        []string      `json:"allowed_methods"`         // Allowed HTTP methods | |
| 	RequiredHeaders       []string      `json:"required_headers"`        // Headers that must be present | |
| 	IPWhitelist           []string      `json:"ip_whitelist"`            // Allowed IP addresses/ranges | |
| 	MaxFileSize           int64         `json:"max_file_size"`           // Maximum file size for uploads | |
| } | |
| 
 | |
| // DefaultPresignedURLSecurityPolicy returns a default security policy | |
| func DefaultPresignedURLSecurityPolicy() *PresignedURLSecurityPolicy { | |
| 	return &PresignedURLSecurityPolicy{ | |
| 		MaxExpirationDuration: 7 * 24 * time.Hour, // 7 days max | |
| 		AllowedMethods:        []string{"GET", "PUT", "POST", "HEAD"}, | |
| 		RequiredHeaders:       []string{}, | |
| 		IPWhitelist:           []string{},             // Empty means no IP restrictions | |
| 		MaxFileSize:           5 * 1024 * 1024 * 1024, // 5GB default | |
| 	} | |
| } | |
| 
 | |
| // ValidatePresignedURLRequest validates a presigned URL request against security policy | |
| func (policy *PresignedURLSecurityPolicy) ValidatePresignedURLRequest(req *PresignedURLRequest) error { | |
| 	// Check expiration duration | |
| 	if req.Expiration > policy.MaxExpirationDuration { | |
| 		return fmt.Errorf("expiration duration %v exceeds maximum allowed %v", req.Expiration, policy.MaxExpirationDuration) | |
| 	} | |
| 
 | |
| 	// Check HTTP method | |
| 	methodAllowed := false | |
| 	for _, allowedMethod := range policy.AllowedMethods { | |
| 		if req.Method == allowedMethod { | |
| 			methodAllowed = true | |
| 			break | |
| 		} | |
| 	} | |
| 	if !methodAllowed { | |
| 		return fmt.Errorf("HTTP method %s is not allowed", req.Method) | |
| 	} | |
| 
 | |
| 	// Check required headers | |
| 	for _, requiredHeader := range policy.RequiredHeaders { | |
| 		if _, exists := req.Headers[requiredHeader]; !exists { | |
| 			return fmt.Errorf("required header %s is missing", requiredHeader) | |
| 		} | |
| 	} | |
| 
 | |
| 	return nil | |
| }
 |