diff --git a/api/auth/center.go b/api/auth/center.go index 145c2d0f..98acd9f2 100644 --- a/api/auth/center.go +++ b/api/auth/center.go @@ -51,6 +51,8 @@ type ( SignatureV4 string SignedFields []string Date string + IsPresigned bool + Expiration time.Duration } ) @@ -58,6 +60,15 @@ const ( accessKeyPartsNum = 2 authHeaderPartsNum = 6 maxFormSizeMemory = 50 * 1048576 // 50 MB + + AmzAlgorithm = "X-Amz-Algorithm" + AmzCredential = "X-Amz-Credential" + AmzSignature = "X-Amz-Signature" + AmzSignedHeaders = "X-Amz-SignedHeaders" + AmzExpires = "X-Amz-Expires" + AmzDate = "X-Amz-Date" + AuthorizationHdr = "Authorization" + ContentTypeHdr = "Content-Type" ) // ErrNoAuthorizationHeader is returned for unauthenticated requests. @@ -114,30 +125,53 @@ func (a *authHeader) getAddress() (*oid.Address, error) { } func (c *center) Authenticate(r *http.Request) (*accessbox.Box, error) { + var ( + err error + authHdr *authHeader + signatureDateTimeStr string + ) + queryValues := r.URL.Query() - if queryValues.Get("X-Amz-Algorithm") == "AWS4-HMAC-SHA256" { - return nil, errors.New("pre-signed form of request is not supported") - } - - authHeaderField := r.Header["Authorization"] - if len(authHeaderField) != 1 { - if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { - return c.checkFormData(r) + if queryValues.Get(AmzAlgorithm) == "AWS4-HMAC-SHA256" { + creds := strings.Split(queryValues.Get(AmzCredential), "/") + if len(creds) != 5 || creds[4] != "aws4_request" { + return nil, fmt.Errorf("bad X-Amz-Credential") } - return nil, ErrNoAuthorizationHeader + authHdr = &authHeader{ + AccessKeyID: creds[0], + Service: creds[3], + Region: creds[2], + SignatureV4: queryValues.Get(AmzSignature), + SignedFields: queryValues[AmzSignedHeaders], + Date: creds[1], + IsPresigned: true, + } + authHdr.Expiration, err = time.ParseDuration(queryValues.Get(AmzExpires) + "s") + if err != nil { + return nil, fmt.Errorf("couldn't parse X-Amz-Expires: %w", err) + } + signatureDateTimeStr = queryValues.Get(AmzDate) + } else { + authHeaderField := r.Header[AuthorizationHdr] + if len(authHeaderField) != 1 { + if strings.HasPrefix(r.Header.Get(ContentTypeHdr), "multipart/form-data") { + return c.checkFormData(r) + } + return nil, ErrNoAuthorizationHeader + } + authHdr, err = c.parseAuthHeader(authHeaderField[0]) + if err != nil { + return nil, err + } + signatureDateTimeStr = r.Header.Get(AmzDate) } - authHeader, err := c.parseAuthHeader(authHeaderField[0]) - if err != nil { - return nil, err - } - - signatureDateTime, err := time.Parse("20060102T150405Z", r.Header.Get("X-Amz-Date")) + signatureDateTime, err := time.Parse("20060102T150405Z", signatureDateTimeStr) if err != nil { return nil, fmt.Errorf("failed to parse x-amz-date header field: %w", err) } - addr, err := authHeader.getAddress() + addr, err := authHdr.getAddress() if err != nil { return nil, err } @@ -147,8 +181,8 @@ func (c *center) Authenticate(r *http.Request) (*accessbox.Box, error) { return nil, err } - clonedRequest := cloneRequest(r, authHeader) - if err = c.checkSign(authHeader, box, clonedRequest, signatureDateTime); err != nil { + clonedRequest := cloneRequest(r, authHdr) + if err = c.checkSign(authHdr, box, clonedRequest, signatureDateTime); err != nil { return nil, err } @@ -212,21 +246,41 @@ func cloneRequest(r *http.Request, authHeader *authHeader) *http.Request { } } + if authHeader.IsPresigned { + otherQuery := otherRequest.URL.Query() + otherQuery.Del(AmzSignature) + otherRequest.URL.RawQuery = otherQuery.Encode() + } + return otherRequest } func (c *center) checkSign(authHeader *authHeader, box *accessbox.Box, request *http.Request, signatureDateTime time.Time) error { awsCreds := credentials.NewStaticCredentials(authHeader.AccessKeyID, box.Gate.AccessKey, "") signer := v4.NewSigner(awsCreds) - signer.DisableURIPathEscaping = true - // body not required - if _, err := signer.Sign(request, nil, authHeader.Service, authHeader.Region, signatureDateTime); err != nil { - return fmt.Errorf("failed to sign temporary HTTP request: %w", err) + var signature string + if authHeader.IsPresigned { + now := time.Now() + if signatureDateTime.Add(authHeader.Expiration).Before(now) { + return apiErrors.GetAPIError(apiErrors.ErrExpiredPresignRequest) + } + if now.Before(signatureDateTime) { + return apiErrors.GetAPIError(apiErrors.ErrBadRequest) + } + if _, err := signer.Presign(request, nil, authHeader.Service, authHeader.Region, authHeader.Expiration, signatureDateTime); err != nil { + return fmt.Errorf("failed to pre-sign temporary HTTP request: %w", err) + } + signature = request.URL.Query().Get(AmzSignature) + } else { + signer.DisableURIPathEscaping = true + if _, err := signer.Sign(request, nil, authHeader.Service, authHeader.Region, signatureDateTime); err != nil { + return fmt.Errorf("failed to sign temporary HTTP request: %w", err) + } + signature = c.reg.getSubmatches(request.Header.Get(AuthorizationHdr))["v4_signature"] } - sms2 := c.reg.getSubmatches(request.Header.Get("Authorization")) - if authHeader.SignatureV4 != sms2["v4_signature"] { + if authHeader.SignatureV4 != signature { return apiErrors.GetAPIError(apiErrors.ErrSignatureDoesNotMatch) } diff --git a/api/headers.go b/api/headers.go index 82fe81e6..ec26ccbf 100644 --- a/api/headers.go +++ b/api/headers.go @@ -10,6 +10,7 @@ const ( AmzDeleteMarker = "X-Amz-Delete-Marker" AmzCopySource = "X-Amz-Copy-Source" AmzCopySourceRange = "X-Amz-Copy-Source-Range" + AmzDate = "X-Amz-Date" LastModified = "Last-Modified" Date = "Date" diff --git a/cmd/authmate/main.go b/cmd/authmate/main.go index def0300c..338438dc 100644 --- a/cmd/authmate/main.go +++ b/cmd/authmate/main.go @@ -5,12 +5,17 @@ import ( "crypto/ecdsa" "encoding/json" "fmt" + "net/http" "os" "os/signal" "strings" "syscall" "time" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + v4 "github.com/aws/aws-sdk-go/aws/signer/v4" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neofs-s3-gw/api" "github.com/nspcc-dev/neofs-s3-gw/authmate" @@ -29,7 +34,8 @@ const ( poolConnectTimeout = 5 * time.Second poolRequestTimeout = 5 * time.Second // a month. - defaultLifetime = 30 * 24 * time.Hour + defaultLifetime = 30 * 24 * time.Hour + defaultPresignedLifetime = 12 * time.Hour ) var ( @@ -48,6 +54,13 @@ var ( logDebugEnabledFlag bool sessionTokenFlag string lifetimeFlag time.Duration + endpointFlag string + bucketFlag string + objectFlag string + methodFlag string + profileFlag string + regionFlag string + secretAccessKeyFlag string containerPolicies string awcCliCredFile string timeoutFlag time.Duration @@ -140,6 +153,7 @@ func appCommands() []*cli.Command { return []*cli.Command{ issueSecret(), obtainSecret(), + generatePresignedURL(), } } @@ -310,6 +324,118 @@ It will be ceil rounded to the nearest amount of epoch.`, } } +func generatePresignedURL() *cli.Command { + return &cli.Command{ + Name: "generate-presigned-url", + Description: `Generate presigned url using AWS credentials. Credentials must be placed in ~/.aws/credentials. +You provide profile to load using --profile flag or explicitly provide credentials and region using +--aws-access-key-id, --aws-secret-access-key, --region. +Note to override credentials you must provide both access key and secret key.`, + Usage: "generate-presigned-url --endpoint http://s3.neofs.devenv:8080 --bucket bucket-name --object object-name --method get --profile aws-profile", + Flags: []cli.Flag{ + &cli.DurationFlag{ + Name: "lifetime", + Usage: `Lifetime of presigned URL. For example 50h30m (note: max time unit is an hour so to set a day you should use 24h). +It will be ceil rounded to the nearest amount of epoch.`, + Required: false, + Destination: &lifetimeFlag, + Value: defaultPresignedLifetime, + }, + &cli.StringFlag{ + Name: "endpoint", + Usage: `Endpoint of s3-gw`, + Required: true, + Destination: &endpointFlag, + }, + &cli.StringFlag{ + Name: "bucket", + Usage: `Bucket name to perform action`, + Required: true, + Destination: &bucketFlag, + }, + &cli.StringFlag{ + Name: "object", + Usage: `Object name to perform action`, + Required: true, + Destination: &objectFlag, + }, + &cli.StringFlag{ + Name: "method", + Usage: `HTTP method to perform action`, + Required: true, + Destination: &methodFlag, + }, + &cli.StringFlag{ + Name: "profile", + Usage: `AWS profile to load`, + Required: false, + Destination: &profileFlag, + }, + &cli.StringFlag{ + Name: "region", + Usage: `AWS region to use in signature (default is taken from ~/.aws/config)`, + Required: false, + Destination: ®ionFlag, + }, + &cli.StringFlag{ + Name: "aws-access-key-id", + Usage: `AWS access key id to sign the URL (default is taken from ~/.aws/credentials)`, + Required: false, + Destination: &accessKeyIDFlag, + }, + &cli.StringFlag{ + Name: "aws-secret-access-key", + Usage: `AWS access secret access key to sign the URL (default is taken from ~/.aws/credentials)`, + Required: false, + Destination: &secretAccessKeyFlag, + }, + }, + Action: func(c *cli.Context) error { + var cfg aws.Config + if regionFlag != "" { + cfg.Region = ®ionFlag + } + if accessKeyIDFlag != "" && secretAccessKeyFlag != "" { + cfg.Credentials = credentials.NewStaticCredentialsFromCreds(credentials.Value{ + AccessKeyID: accessKeyIDFlag, + SecretAccessKey: secretAccessKeyFlag, + }) + } + + sess, err := session.NewSessionWithOptions(session.Options{ + Config: cfg, + Profile: profileFlag, + SharedConfigState: session.SharedConfigEnable, + }) + if err != nil { + return fmt.Errorf("couldn't get credentials: %w", err) + } + + signer := v4.NewSigner(sess.Config.Credentials) + req, err := http.NewRequest(strings.ToUpper(methodFlag), fmt.Sprintf("%s/%s/%s", endpointFlag, bucketFlag, objectFlag), nil) + if err != nil { + return err + } + + date := time.Now().UTC() + req.Header.Set(api.AmzDate, date.Format("20060102T150405Z")) + + if _, err = signer.Presign(req, nil, "s3", *sess.Config.Region, lifetimeFlag, date); err != nil { + return err + } + + res := &struct{ URL string }{ + URL: req.URL.String(), + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(res) + }, + } +} + func parsePolicies(val string) (authmate.ContainerPolicies, error) { if val == "" { return nil, nil