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

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
}