s3_presigned_url_iam.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. package s3api
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "fmt"
  7. "net/http"
  8. "net/url"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/seaweedfs/seaweedfs/weed/glog"
  13. "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
  14. "github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
  15. )
  16. // S3PresignedURLManager handles IAM integration for presigned URLs
  17. type S3PresignedURLManager struct {
  18. s3iam *S3IAMIntegration
  19. }
  20. // NewS3PresignedURLManager creates a new presigned URL manager with IAM integration
  21. func NewS3PresignedURLManager(s3iam *S3IAMIntegration) *S3PresignedURLManager {
  22. return &S3PresignedURLManager{
  23. s3iam: s3iam,
  24. }
  25. }
  26. // PresignedURLRequest represents a request to generate a presigned URL
  27. type PresignedURLRequest struct {
  28. Method string `json:"method"` // HTTP method (GET, PUT, POST, DELETE)
  29. Bucket string `json:"bucket"` // S3 bucket name
  30. ObjectKey string `json:"object_key"` // S3 object key
  31. Expiration time.Duration `json:"expiration"` // URL expiration duration
  32. SessionToken string `json:"session_token"` // JWT session token for IAM
  33. Headers map[string]string `json:"headers"` // Additional headers to sign
  34. QueryParams map[string]string `json:"query_params"` // Additional query parameters
  35. }
  36. // PresignedURLResponse represents the generated presigned URL
  37. type PresignedURLResponse struct {
  38. URL string `json:"url"` // The presigned URL
  39. Method string `json:"method"` // HTTP method
  40. Headers map[string]string `json:"headers"` // Required headers
  41. ExpiresAt time.Time `json:"expires_at"` // URL expiration time
  42. SignedHeaders []string `json:"signed_headers"` // List of signed headers
  43. CanonicalQuery string `json:"canonical_query"` // Canonical query string
  44. }
  45. // ValidatePresignedURLWithIAM validates a presigned URL request using IAM policies
  46. func (iam *IdentityAccessManagement) ValidatePresignedURLWithIAM(r *http.Request, identity *Identity) s3err.ErrorCode {
  47. if iam.iamIntegration == nil {
  48. // Fall back to standard validation
  49. return s3err.ErrNone
  50. }
  51. // Extract bucket and object from request
  52. bucket, object := s3_constants.GetBucketAndObject(r)
  53. // Determine the S3 action from HTTP method and path
  54. action := determineS3ActionFromRequest(r, bucket, object)
  55. // Check if the user has permission for this action
  56. ctx := r.Context()
  57. sessionToken := extractSessionTokenFromPresignedURL(r)
  58. if sessionToken == "" {
  59. // No session token in presigned URL - use standard auth
  60. return s3err.ErrNone
  61. }
  62. // Parse JWT token to extract role and session information
  63. tokenClaims, err := parseJWTToken(sessionToken)
  64. if err != nil {
  65. glog.V(3).Infof("Failed to parse JWT token in presigned URL: %v", err)
  66. return s3err.ErrAccessDenied
  67. }
  68. // Extract role information from token claims
  69. roleName, ok := tokenClaims["role"].(string)
  70. if !ok || roleName == "" {
  71. glog.V(3).Info("No role found in JWT token for presigned URL")
  72. return s3err.ErrAccessDenied
  73. }
  74. sessionName, ok := tokenClaims["snam"].(string)
  75. if !ok || sessionName == "" {
  76. sessionName = "presigned-session" // Default fallback
  77. }
  78. // Use the principal ARN directly from token claims, or build it if not available
  79. principalArn, ok := tokenClaims["principal"].(string)
  80. if !ok || principalArn == "" {
  81. // Fallback: extract role name from role ARN and build principal ARN
  82. roleNameOnly := roleName
  83. if strings.Contains(roleName, "/") {
  84. parts := strings.Split(roleName, "/")
  85. roleNameOnly = parts[len(parts)-1]
  86. }
  87. principalArn = fmt.Sprintf("arn:seaweed:sts::assumed-role/%s/%s", roleNameOnly, sessionName)
  88. }
  89. // Create IAM identity for authorization using extracted information
  90. iamIdentity := &IAMIdentity{
  91. Name: identity.Name,
  92. Principal: principalArn,
  93. SessionToken: sessionToken,
  94. Account: identity.Account,
  95. }
  96. // Authorize using IAM
  97. errCode := iam.iamIntegration.AuthorizeAction(ctx, iamIdentity, action, bucket, object, r)
  98. if errCode != s3err.ErrNone {
  99. glog.V(3).Infof("IAM authorization failed for presigned URL: principal=%s action=%s bucket=%s object=%s",
  100. iamIdentity.Principal, action, bucket, object)
  101. return errCode
  102. }
  103. glog.V(3).Infof("IAM authorization succeeded for presigned URL: principal=%s action=%s bucket=%s object=%s",
  104. iamIdentity.Principal, action, bucket, object)
  105. return s3err.ErrNone
  106. }
  107. // GeneratePresignedURLWithIAM generates a presigned URL with IAM policy validation
  108. func (pm *S3PresignedURLManager) GeneratePresignedURLWithIAM(ctx context.Context, req *PresignedURLRequest, baseURL string) (*PresignedURLResponse, error) {
  109. if pm.s3iam == nil || !pm.s3iam.enabled {
  110. return nil, fmt.Errorf("IAM integration not enabled")
  111. }
  112. // Validate session token and get identity
  113. // Use a proper ARN format for the principal
  114. principalArn := fmt.Sprintf("arn:seaweed:sts::assumed-role/PresignedUser/presigned-session")
  115. iamIdentity := &IAMIdentity{
  116. SessionToken: req.SessionToken,
  117. Principal: principalArn,
  118. Name: "presigned-user",
  119. Account: &AccountAdmin,
  120. }
  121. // Determine S3 action from method
  122. action := determineS3ActionFromMethodAndPath(req.Method, req.Bucket, req.ObjectKey)
  123. // Check IAM permissions before generating URL
  124. authRequest := &http.Request{
  125. Method: req.Method,
  126. URL: &url.URL{Path: "/" + req.Bucket + "/" + req.ObjectKey},
  127. Header: make(http.Header),
  128. }
  129. authRequest.Header.Set("Authorization", "Bearer "+req.SessionToken)
  130. authRequest = authRequest.WithContext(ctx)
  131. errCode := pm.s3iam.AuthorizeAction(ctx, iamIdentity, action, req.Bucket, req.ObjectKey, authRequest)
  132. if errCode != s3err.ErrNone {
  133. return nil, fmt.Errorf("IAM authorization failed: user does not have permission for action %s on resource %s/%s", action, req.Bucket, req.ObjectKey)
  134. }
  135. // Generate presigned URL with validated permissions
  136. return pm.generatePresignedURL(req, baseURL, iamIdentity)
  137. }
  138. // generatePresignedURL creates the actual presigned URL
  139. func (pm *S3PresignedURLManager) generatePresignedURL(req *PresignedURLRequest, baseURL string, identity *IAMIdentity) (*PresignedURLResponse, error) {
  140. // Calculate expiration time
  141. expiresAt := time.Now().Add(req.Expiration)
  142. // Build the base URL
  143. urlPath := "/" + req.Bucket
  144. if req.ObjectKey != "" {
  145. urlPath += "/" + req.ObjectKey
  146. }
  147. // Create query parameters for AWS signature v4
  148. queryParams := make(map[string]string)
  149. for k, v := range req.QueryParams {
  150. queryParams[k] = v
  151. }
  152. // Add AWS signature v4 parameters
  153. queryParams["X-Amz-Algorithm"] = "AWS4-HMAC-SHA256"
  154. queryParams["X-Amz-Credential"] = fmt.Sprintf("seaweedfs/%s/us-east-1/s3/aws4_request", expiresAt.Format("20060102"))
  155. queryParams["X-Amz-Date"] = expiresAt.Format("20060102T150405Z")
  156. queryParams["X-Amz-Expires"] = strconv.Itoa(int(req.Expiration.Seconds()))
  157. queryParams["X-Amz-SignedHeaders"] = "host"
  158. // Add session token if available
  159. if identity.SessionToken != "" {
  160. queryParams["X-Amz-Security-Token"] = identity.SessionToken
  161. }
  162. // Build canonical query string
  163. canonicalQuery := buildCanonicalQuery(queryParams)
  164. // For now, we'll create a mock signature
  165. // In production, this would use proper AWS signature v4 signing
  166. mockSignature := generateMockSignature(req.Method, urlPath, canonicalQuery, identity.SessionToken)
  167. queryParams["X-Amz-Signature"] = mockSignature
  168. // Build final URL
  169. finalQuery := buildCanonicalQuery(queryParams)
  170. fullURL := baseURL + urlPath + "?" + finalQuery
  171. // Prepare response
  172. headers := make(map[string]string)
  173. for k, v := range req.Headers {
  174. headers[k] = v
  175. }
  176. return &PresignedURLResponse{
  177. URL: fullURL,
  178. Method: req.Method,
  179. Headers: headers,
  180. ExpiresAt: expiresAt,
  181. SignedHeaders: []string{"host"},
  182. CanonicalQuery: canonicalQuery,
  183. }, nil
  184. }
  185. // Helper functions
  186. // determineS3ActionFromRequest determines the S3 action based on HTTP request
  187. func determineS3ActionFromRequest(r *http.Request, bucket, object string) Action {
  188. return determineS3ActionFromMethodAndPath(r.Method, bucket, object)
  189. }
  190. // determineS3ActionFromMethodAndPath determines the S3 action based on method and path
  191. func determineS3ActionFromMethodAndPath(method, bucket, object string) Action {
  192. switch method {
  193. case "GET":
  194. if object == "" {
  195. return s3_constants.ACTION_LIST // ListBucket
  196. } else {
  197. return s3_constants.ACTION_READ // GetObject
  198. }
  199. case "PUT", "POST":
  200. return s3_constants.ACTION_WRITE // PutObject
  201. case "DELETE":
  202. if object == "" {
  203. return s3_constants.ACTION_DELETE_BUCKET // DeleteBucket
  204. } else {
  205. return s3_constants.ACTION_WRITE // DeleteObject (uses WRITE action)
  206. }
  207. case "HEAD":
  208. if object == "" {
  209. return s3_constants.ACTION_LIST // HeadBucket
  210. } else {
  211. return s3_constants.ACTION_READ // HeadObject
  212. }
  213. default:
  214. return s3_constants.ACTION_READ // Default to read
  215. }
  216. }
  217. // extractSessionTokenFromPresignedURL extracts session token from presigned URL query parameters
  218. func extractSessionTokenFromPresignedURL(r *http.Request) string {
  219. // Check for X-Amz-Security-Token in query parameters
  220. if token := r.URL.Query().Get("X-Amz-Security-Token"); token != "" {
  221. return token
  222. }
  223. // Check for session token in other possible locations
  224. if token := r.URL.Query().Get("SessionToken"); token != "" {
  225. return token
  226. }
  227. return ""
  228. }
  229. // buildCanonicalQuery builds a canonical query string for AWS signature
  230. func buildCanonicalQuery(params map[string]string) string {
  231. var keys []string
  232. for k := range params {
  233. keys = append(keys, k)
  234. }
  235. // Sort keys for canonical order
  236. for i := 0; i < len(keys); i++ {
  237. for j := i + 1; j < len(keys); j++ {
  238. if keys[i] > keys[j] {
  239. keys[i], keys[j] = keys[j], keys[i]
  240. }
  241. }
  242. }
  243. var parts []string
  244. for _, k := range keys {
  245. parts = append(parts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(params[k])))
  246. }
  247. return strings.Join(parts, "&")
  248. }
  249. // generateMockSignature generates a mock signature for testing purposes
  250. func generateMockSignature(method, path, query, sessionToken string) string {
  251. // This is a simplified signature for demonstration
  252. // In production, use proper AWS signature v4 calculation
  253. data := fmt.Sprintf("%s\n%s\n%s\n%s", method, path, query, sessionToken)
  254. hash := sha256.Sum256([]byte(data))
  255. return hex.EncodeToString(hash[:])[:16] // Truncate for readability
  256. }
  257. // ValidatePresignedURLExpiration validates that a presigned URL hasn't expired
  258. func ValidatePresignedURLExpiration(r *http.Request) error {
  259. query := r.URL.Query()
  260. // Get X-Amz-Date and X-Amz-Expires
  261. dateStr := query.Get("X-Amz-Date")
  262. expiresStr := query.Get("X-Amz-Expires")
  263. if dateStr == "" || expiresStr == "" {
  264. return fmt.Errorf("missing required presigned URL parameters")
  265. }
  266. // Parse date (always in UTC)
  267. signedDate, err := time.Parse("20060102T150405Z", dateStr)
  268. if err != nil {
  269. return fmt.Errorf("invalid X-Amz-Date format: %v", err)
  270. }
  271. // Parse expires
  272. expires, err := strconv.Atoi(expiresStr)
  273. if err != nil {
  274. return fmt.Errorf("invalid X-Amz-Expires format: %v", err)
  275. }
  276. // Check expiration - compare in UTC
  277. expirationTime := signedDate.Add(time.Duration(expires) * time.Second)
  278. now := time.Now().UTC()
  279. if now.After(expirationTime) {
  280. return fmt.Errorf("presigned URL has expired")
  281. }
  282. return nil
  283. }
  284. // PresignedURLSecurityPolicy represents security constraints for presigned URL generation
  285. type PresignedURLSecurityPolicy struct {
  286. MaxExpirationDuration time.Duration `json:"max_expiration_duration"` // Maximum allowed expiration
  287. AllowedMethods []string `json:"allowed_methods"` // Allowed HTTP methods
  288. RequiredHeaders []string `json:"required_headers"` // Headers that must be present
  289. IPWhitelist []string `json:"ip_whitelist"` // Allowed IP addresses/ranges
  290. MaxFileSize int64 `json:"max_file_size"` // Maximum file size for uploads
  291. }
  292. // DefaultPresignedURLSecurityPolicy returns a default security policy
  293. func DefaultPresignedURLSecurityPolicy() *PresignedURLSecurityPolicy {
  294. return &PresignedURLSecurityPolicy{
  295. MaxExpirationDuration: 7 * 24 * time.Hour, // 7 days max
  296. AllowedMethods: []string{"GET", "PUT", "POST", "HEAD"},
  297. RequiredHeaders: []string{},
  298. IPWhitelist: []string{}, // Empty means no IP restrictions
  299. MaxFileSize: 5 * 1024 * 1024 * 1024, // 5GB default
  300. }
  301. }
  302. // ValidatePresignedURLRequest validates a presigned URL request against security policy
  303. func (policy *PresignedURLSecurityPolicy) ValidatePresignedURLRequest(req *PresignedURLRequest) error {
  304. // Check expiration duration
  305. if req.Expiration > policy.MaxExpirationDuration {
  306. return fmt.Errorf("expiration duration %v exceeds maximum allowed %v", req.Expiration, policy.MaxExpirationDuration)
  307. }
  308. // Check HTTP method
  309. methodAllowed := false
  310. for _, allowedMethod := range policy.AllowedMethods {
  311. if req.Method == allowedMethod {
  312. methodAllowed = true
  313. break
  314. }
  315. }
  316. if !methodAllowed {
  317. return fmt.Errorf("HTTP method %s is not allowed", req.Method)
  318. }
  319. // Check required headers
  320. for _, requiredHeader := range policy.RequiredHeaders {
  321. if _, exists := req.Headers[requiredHeader]; !exists {
  322. return fmt.Errorf("required header %s is missing", requiredHeader)
  323. }
  324. }
  325. return nil
  326. }