[#529] Add presign URLs support

Signed-off-by: Denis Kirillov <denis@nspcc.ru>
This commit is contained in:
Denis Kirillov 2022-06-14 18:15:29 +03:00 committed by Alex Vanin
parent 83967312e0
commit d521af2065
3 changed files with 206 additions and 25 deletions

View file

@ -51,6 +51,8 @@ type (
SignatureV4 string SignatureV4 string
SignedFields []string SignedFields []string
Date string Date string
IsPresigned bool
Expiration time.Duration
} }
) )
@ -58,6 +60,15 @@ const (
accessKeyPartsNum = 2 accessKeyPartsNum = 2
authHeaderPartsNum = 6 authHeaderPartsNum = 6
maxFormSizeMemory = 50 * 1048576 // 50 MB 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. // 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) { func (c *center) Authenticate(r *http.Request) (*accessbox.Box, error) {
var (
err error
authHdr *authHeader
signatureDateTimeStr string
)
queryValues := r.URL.Query() queryValues := r.URL.Query()
if queryValues.Get("X-Amz-Algorithm") == "AWS4-HMAC-SHA256" { if queryValues.Get(AmzAlgorithm) == "AWS4-HMAC-SHA256" {
return nil, errors.New("pre-signed form of request is not supported") creds := strings.Split(queryValues.Get(AmzCredential), "/")
} if len(creds) != 5 || creds[4] != "aws4_request" {
return nil, fmt.Errorf("bad X-Amz-Credential")
authHeaderField := r.Header["Authorization"]
if len(authHeaderField) != 1 {
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
return c.checkFormData(r)
} }
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]) signatureDateTime, err := time.Parse("20060102T150405Z", signatureDateTimeStr)
if err != nil {
return nil, err
}
signatureDateTime, err := time.Parse("20060102T150405Z", r.Header.Get("X-Amz-Date"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse x-amz-date header field: %w", err) 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 { if err != nil {
return nil, err return nil, err
} }
@ -147,8 +181,8 @@ func (c *center) Authenticate(r *http.Request) (*accessbox.Box, error) {
return nil, err return nil, err
} }
clonedRequest := cloneRequest(r, authHeader) clonedRequest := cloneRequest(r, authHdr)
if err = c.checkSign(authHeader, box, clonedRequest, signatureDateTime); err != nil { if err = c.checkSign(authHdr, box, clonedRequest, signatureDateTime); err != nil {
return nil, err 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 return otherRequest
} }
func (c *center) checkSign(authHeader *authHeader, box *accessbox.Box, request *http.Request, signatureDateTime time.Time) error { func (c *center) checkSign(authHeader *authHeader, box *accessbox.Box, request *http.Request, signatureDateTime time.Time) error {
awsCreds := credentials.NewStaticCredentials(authHeader.AccessKeyID, box.Gate.AccessKey, "") awsCreds := credentials.NewStaticCredentials(authHeader.AccessKeyID, box.Gate.AccessKey, "")
signer := v4.NewSigner(awsCreds) signer := v4.NewSigner(awsCreds)
signer.DisableURIPathEscaping = true
// body not required var signature string
if _, err := signer.Sign(request, nil, authHeader.Service, authHeader.Region, signatureDateTime); err != nil { if authHeader.IsPresigned {
return fmt.Errorf("failed to sign temporary HTTP request: %w", err) 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 != signature {
if authHeader.SignatureV4 != sms2["v4_signature"] {
return apiErrors.GetAPIError(apiErrors.ErrSignatureDoesNotMatch) return apiErrors.GetAPIError(apiErrors.ErrSignatureDoesNotMatch)
} }

View file

@ -10,6 +10,7 @@ const (
AmzDeleteMarker = "X-Amz-Delete-Marker" AmzDeleteMarker = "X-Amz-Delete-Marker"
AmzCopySource = "X-Amz-Copy-Source" AmzCopySource = "X-Amz-Copy-Source"
AmzCopySourceRange = "X-Amz-Copy-Source-Range" AmzCopySourceRange = "X-Amz-Copy-Source-Range"
AmzDate = "X-Amz-Date"
LastModified = "Last-Modified" LastModified = "Last-Modified"
Date = "Date" Date = "Date"

View file

@ -5,12 +5,17 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
"syscall" "syscall"
"time" "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/neo-go/pkg/crypto/keys"
"github.com/nspcc-dev/neofs-s3-gw/api" "github.com/nspcc-dev/neofs-s3-gw/api"
"github.com/nspcc-dev/neofs-s3-gw/authmate" "github.com/nspcc-dev/neofs-s3-gw/authmate"
@ -29,7 +34,8 @@ const (
poolConnectTimeout = 5 * time.Second poolConnectTimeout = 5 * time.Second
poolRequestTimeout = 5 * time.Second poolRequestTimeout = 5 * time.Second
// a month. // a month.
defaultLifetime = 30 * 24 * time.Hour defaultLifetime = 30 * 24 * time.Hour
defaultPresignedLifetime = 12 * time.Hour
) )
var ( var (
@ -48,6 +54,13 @@ var (
logDebugEnabledFlag bool logDebugEnabledFlag bool
sessionTokenFlag string sessionTokenFlag string
lifetimeFlag time.Duration lifetimeFlag time.Duration
endpointFlag string
bucketFlag string
objectFlag string
methodFlag string
profileFlag string
regionFlag string
secretAccessKeyFlag string
containerPolicies string containerPolicies string
awcCliCredFile string awcCliCredFile string
timeoutFlag time.Duration timeoutFlag time.Duration
@ -140,6 +153,7 @@ func appCommands() []*cli.Command {
return []*cli.Command{ return []*cli.Command{
issueSecret(), issueSecret(),
obtainSecret(), 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: &regionFlag,
},
&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 = &regionFlag
}
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) { func parsePolicies(val string) (authmate.ContainerPolicies, error) {
if val == "" { if val == "" {
return nil, nil return nil, nil