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