diff --git a/auth_aksk_options.go b/auth_aksk_options.go new file mode 100644 index 000000000..4057ba4cd --- /dev/null +++ b/auth_aksk_options.go @@ -0,0 +1,30 @@ +package golangsdk + +// AKSKAuthOptions presents the required information for AK/SK auth +type AKSKAuthOptions struct { + // IdentityEndpoint specifies the HTTP endpoint that is required to work with + // the Identity API of the appropriate version. While it's ultimately needed by + // all of the identity services, it will often be populated by a provider-level + // function. + // + // The IdentityEndpoint is typically referred to as the "auth_url" or + // "OS_AUTH_URL" in the information provided by the cloud operator. + IdentityEndpoint string `json:"-"` + + // user project id + ProjectId string + + // region + Region string + + // cloud service domain, example: myhwclouds.com + Domain string + + AccessKey string //Access Key + SecretKey string //Secret key +} + +// Implements the method of AuthOptionsProvider +func (opts AKSKAuthOptions) GetIdentityEndpoint() string { + return opts.IdentityEndpoint +} diff --git a/auth_option_provider.go b/auth_option_provider.go new file mode 100644 index 000000000..755a3eb93 --- /dev/null +++ b/auth_option_provider.go @@ -0,0 +1,6 @@ +package golangsdk + +// AuthOptionsProvider presents the base of an auth options implementation +type AuthOptionsProvider interface { + GetIdentityEndpoint() string +} diff --git a/auth_options.go b/auth_options.go index d138dfcf0..243a4535a 100644 --- a/auth_options.go +++ b/auth_options.go @@ -298,6 +298,11 @@ func (opts *AuthOptions) AuthTokenID() string { return "" } +// Implements the method of AuthOptionsProvider +func (opts AuthOptions) GetIdentityEndpoint() string { + return opts.IdentityEndpoint +} + type scopeInfo struct { ProjectID string ProjectName string diff --git a/openstack/client.go b/openstack/client.go index 70c09408f..8a59fe28e 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -9,8 +9,11 @@ import ( "github.com/huaweicloud/golangsdk" tokens2 "github.com/huaweicloud/golangsdk/openstack/identity/v2/tokens" + "github.com/huaweicloud/golangsdk/openstack/identity/v3/endpoints" + "github.com/huaweicloud/golangsdk/openstack/identity/v3/services" tokens3 "github.com/huaweicloud/golangsdk/openstack/identity/v3/tokens" "github.com/huaweicloud/golangsdk/openstack/utils" + "github.com/huaweicloud/golangsdk/pagination" ) const ( @@ -99,7 +102,7 @@ func AuthenticatedClient(options golangsdk.AuthOptions) (*golangsdk.ProviderClie // Authenticate or re-authenticate against the most recent identity service // supported at the provided endpoint. -func Authenticate(client *golangsdk.ProviderClient, options golangsdk.AuthOptions) error { +func Authenticate(client *golangsdk.ProviderClient, options golangsdk.AuthOptionsProvider) error { versions := []*utils.Version{ {ID: v2, Priority: 20, Suffix: "/v2.0/"}, {ID: v3, Priority: 30, Suffix: "/v3/"}, @@ -110,15 +113,28 @@ func Authenticate(client *golangsdk.ProviderClient, options golangsdk.AuthOption return err } - switch chosen.ID { - case v2: - return v2auth(client, endpoint, options, golangsdk.EndpointOpts{}) - case v3: - return v3auth(client, endpoint, &options, golangsdk.EndpointOpts{}) - default: - // The switch statement must be out of date from the versions list. - return fmt.Errorf("Unrecognized identity version: %s", chosen.ID) + authOptions, isTokenAuthOptions := options.(golangsdk.AuthOptions) + + if isTokenAuthOptions { + switch chosen.ID { + case v2: + return v2auth(client, endpoint, authOptions, golangsdk.EndpointOpts{}) + case v3: + return v3auth(client, endpoint, &authOptions, golangsdk.EndpointOpts{}) + default: + // The switch statement must be out of date from the versions list. + return fmt.Errorf("Unrecognized identity version: %s", chosen.ID) + } + } else { + akskAuthOptions, isAkSkOptions := options.(golangsdk.AKSKAuthOptions) + + if isAkSkOptions { + return v3AKSKAuth(client, endpoint, akskAuthOptions, golangsdk.EndpointOpts{}) + } else { + return fmt.Errorf("Unrecognized auth options provider: %s", reflect.TypeOf(options)) + } } + } // AuthenticateV2 explicitly authenticates against the identity v2 endpoint. @@ -239,6 +255,86 @@ func v3auth(client *golangsdk.ProviderClient, endpoint string, opts tokens3.Auth return nil } +func getEntryByServiceId(entries []tokens3.CatalogEntry, serviceId string) *tokens3.CatalogEntry { + if entries == nil { + return nil + } + + for idx, _ := range entries { + if entries[idx].ID == serviceId { + return &entries[idx] + } + } + + return nil +} + +func v3AKSKAuth(client *golangsdk.ProviderClient, endpoint string, options golangsdk.AKSKAuthOptions, eo golangsdk.EndpointOpts) error { + v3Client, err := NewIdentityV3(client, eo) + if err != nil { + return err + } + + if endpoint != "" { + v3Client.Endpoint = endpoint + } + + v3Client.AKSKAuthOptions = options + v3Client.ProjectID = options.ProjectId + + var entries = make([]tokens3.CatalogEntry, 0, 1) + services.List(v3Client, services.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + serviceLst, err := services.ExtractServices(page) + if err != nil { + return false, err + } + + for _, svc := range serviceLst { + entry := tokens3.CatalogEntry{ + Type: svc.Type, + //Name: svc.Name, + ID: svc.ID, + } + entries = append(entries, entry) + } + + return true, nil + }) + + endpoints.List(v3Client, endpoints.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + endpoints, err := endpoints.ExtractEndpoints(page) + if err != nil { + return false, err + } + + for _, endpoint := range endpoints { + entry := getEntryByServiceId(entries, endpoint.ServiceID) + + if entry != nil { + entry.Endpoints = append(entry.Endpoints, tokens3.Endpoint{ + URL: strings.Replace(endpoint.URL, "$(tenant_id)s", options.ProjectId, -1), + Region: endpoint.Region, + Interface: string(endpoint.Availability), + ID: endpoint.ID, + }) + } + } + + client.EndpointLocator = func(opts golangsdk.EndpointOpts) (string, error) { + if opts.Region == "" { + opts.Region = options.Region + } + return V3EndpointURL(&tokens3.ServiceCatalog{ + Entries: entries, + }, opts) + } + + return true, nil + }) + + return nil +} + // NewIdentityV2 creates a ServiceClient that may be used to interact with the // v2 identity service. func NewIdentityV2(client *golangsdk.ProviderClient, eo golangsdk.EndpointOpts) (*golangsdk.ServiceClient, error) { diff --git a/provider_client.go b/provider_client.go index 11904f00b..bba8c89c8 100644 --- a/provider_client.go +++ b/provider_client.go @@ -74,6 +74,10 @@ type ProviderClient struct { // authentication functions for different Identity service versions. ReauthFunc func() error + // AKSKAuthOptions provides the value for AK/SK authentication, it should be nil if you use token authentication, + // Otherwise, it must have a value + AKSKAuthOptions AKSKAuthOptions + mut *sync.RWMutex reauthmut *reauthlock @@ -217,6 +221,13 @@ func (client *ProviderClient) Request(method, url string, options *RequestOpts) prereqtok := req.Header.Get("X-Auth-Token") + if client.AKSKAuthOptions.AccessKey != "" { + Sign(req, SignOptions{ + AccessKey: client.AKSKAuthOptions.AccessKey, + SecretKey: client.AKSKAuthOptions.SecretKey, + }) + } + // Issue the request. resp, err := client.HTTPClient.Do(req) if err != nil { diff --git a/signer_helper.go b/signer_helper.go new file mode 100644 index 000000000..7bc0423bc --- /dev/null +++ b/signer_helper.go @@ -0,0 +1,479 @@ +package golangsdk + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/textproto" + "net/url" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +// MemoryCache presents a thread safe memory cache +type MemoryCache struct { + sync.Mutex // handling r/w for cache + cacheHolder map[string]string // cache holder + cacheKeys []string // cache keys + MaxCount int // max cache entry count +} + +// NewCache inits an new MemoryCache +func NewCache(maxCount int) *MemoryCache { + return &MemoryCache{ + cacheHolder: make(map[string]string, maxCount), + MaxCount: maxCount, + } +} + +// Add an new cache item +func (cache *MemoryCache) Add(cacheKey string, cacheData string) { + cache.Lock() + defer cache.Unlock() + + if len(cache.cacheKeys) >= cache.MaxCount && len(cache.cacheKeys) > 1 { + delete(cache.cacheHolder, cache.cacheKeys[0]) // delete first item + cache.cacheKeys = append(cache.cacheKeys[1:]) // pop first one + } + + cache.cacheHolder[cacheKey] = cacheData + cache.cacheKeys = append(cache.cacheKeys, cacheKey) +} + +// Get a cache item by its key +func (cache *MemoryCache) Get(cacheKey string) string { + cache.Lock() + defer cache.Unlock() + + return cache.cacheHolder[cacheKey] +} + +//caseInsencitiveStringArray represents string case insensitive sorting operations +type caseInsencitiveStringArray []string + +// noEscape specifies whether the character should be encoded or not +var noEscape [256]bool + +func init() { + // refer to https://docs.oracle.com/javase/7/docs/api/java/net/URLEncoder.html + for i := 0; i < len(noEscape); i++ { + noEscape[i] = (i >= 'A' && i <= 'Z') || + (i >= 'a' && i <= 'z') || + (i >= '0' && i <= '9') || + i == '.' || + i == '-' || + i == '_' || + i == '~' //java-sdk-core 3.0.1 HttpUtils.urlEncode + } +} + +// SignOptions represents the options during signing http request, it is concurency safely +type SignOptions struct { + AccessKey string //Access Key + SecretKey string //Secret key + RegionName string // Region name + ServiceName string // Service Name + EnableCacheSignKey bool // Cache sign key for one day or not cache, cache is disabled by default + encodeUrl bool //internal use + SignAlgorithm string //The algorithm used for sign, the default value is "SDK-HMAC-SHA256" if you don't set its value + TimeOffsetInseconds int64 // TimeOffsetInseconds is used for adjust x-sdk-date if set its value +} + +// StringBuilder wraps bytes.Buffer to implement a high performance string builder +type StringBuilder struct { + builder bytes.Buffer //string storage +} + +// reqSignParams represents the option values used for signing http request +type reqSignParams struct { + SignOptions + RequestTime time.Time + Req *http.Request +} + +// signKeyCacheEntry represents the cache entry of sign key +type signKeyCacheEntry struct { + Key []byte // sign key + NumberOfDaysSinceEpoch int64 // number of days since epoch +} + +// The default sign algorithm +const SignAlgorithmHMACSHA256 = "SDK-HMAC-SHA256" + +// The header key of content hash value +const ContentSha256HeaderKey = "x-sdk-content-sha256" + +//A regular for searching empty string +var spaceRegexp = regexp.MustCompile(`\s+`) + +// cache sign key +var cache = NewCache(300) + +//Sign manipulates the http.Request instance with some required authentication headers for SK/SK auth +func Sign(req *http.Request, signOptions SignOptions) { + signOptions.AccessKey = strings.TrimSpace(signOptions.AccessKey) + signOptions.SecretKey = strings.TrimSpace(signOptions.SecretKey) + signOptions.encodeUrl = true + + signParams := reqSignParams{ + SignOptions: signOptions, + RequestTime: time.Now(), + Req: req, + } + + //t, _ := time.Parse(time.RFC3339, "2018-04-15T04:28:22+00:00") + //signParams.RequestTime = t + + if signParams.SignAlgorithm == "" { + signParams.SignAlgorithm = SignAlgorithmHMACSHA256 + } + + addRequiredHeaders(req, signParams.getFormattedSigningDateTime()) + contentSha256 := "" + + if v, ok := req.Header[textproto.CanonicalMIMEHeaderKey(ContentSha256HeaderKey)]; !ok { + contentSha256 = calculateContentHash(req) + } else { + contentSha256 = v[0] + } + + canonicalRequest := createCanonicalRequest(signParams, contentSha256) + + /*fmt.Println("canonicalRequest: " + canonicalRequest) + fmt.Println("*****")*/ + + strToSign := createStringToSign(canonicalRequest, signParams) + signKey := deriveSigningKey(signParams) + signature := computeSignature(strToSign, signKey, signParams.SignAlgorithm) + + req.Header.Set("Authorization", buildAuthorizationHeader(signParams, signature)) +} + +// deriveSigningKey returns a sign key from cache, or build it and insert it into cache +func deriveSigningKey(signParam reqSignParams) []byte { + if signParam.EnableCacheSignKey { + cacheKey := strings.Join([]string{signParam.SecretKey, + signParam.RegionName, + signParam.ServiceName, + }, "-") + + cacheData := cache.Get(cacheKey) + + if cacheData != "" { + var signKey signKeyCacheEntry + json.Unmarshal([]byte(cacheData), &signKey) + + if signKey.NumberOfDaysSinceEpoch == signParam.getDaysSinceEpon() { + return signKey.Key + } + } + + signKey := buildSignKey(signParam) + signKeyStr, _ := json.Marshal(signKeyCacheEntry{ + Key: signKey, + NumberOfDaysSinceEpoch: signParam.getDaysSinceEpon(), + }) + cache.Add(cacheKey, string(signKeyStr)) + return signKey + } else { + return buildSignKey(signParam) + } +} + +func buildSignKey(signParam reqSignParams) []byte { + var kSecret StringBuilder + kSecret.Write("SDK").Write(signParam.SecretKey) + + kDate := computeSignature(signParam.getFormattedSigningDate(), kSecret.GetBytes(), signParam.SignAlgorithm) + kRegion := computeSignature(signParam.RegionName, kDate, signParam.SignAlgorithm) + kService := computeSignature(signParam.ServiceName, kRegion, signParam.SignAlgorithm) + return computeSignature("sdk_request", kService, signParam.SignAlgorithm) +} + +//HmacSha256 implements the Keyed-Hash Message Authentication Code computation +func HmacSha256(data string, key []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + return mac.Sum(nil) +} + +// HashSha256 is a wrapper for sha256 implementation +func HashSha256(msg []byte) []byte { + sh256 := sha256.New() + sh256.Write(msg) + + return sh256.Sum(nil) +} + +// buildAuthorizationHeader builds the authentication header value +func buildAuthorizationHeader(signParam reqSignParams, signature []byte) string { + var signingCredentials StringBuilder + signingCredentials.Write(signParam.AccessKey).Write("/").Write(signParam.getScope()) + + credential := "Credential=" + signingCredentials.ToString() + signerHeaders := "SignedHeaders=" + getSignedHeadersString(signParam.Req) + signatureHeader := "Signature=" + hex.EncodeToString(signature) + + return signParam.SignAlgorithm + " " + strings.Join([]string{ + credential, + signerHeaders, + signatureHeader, + }, ", ") +} + +// computeSignature computers the signature with the specified algorithm +// and it only supports SDK-HMAC-SHA256 in currently +func computeSignature(signData string, key []byte, algorithm string) []byte { + if algorithm == SignAlgorithmHMACSHA256 { + return HmacSha256(signData, key) + } else { + log.Fatalf("Unsupported algorithm %s, please use %s and try again", algorithm, SignAlgorithmHMACSHA256) + return nil + } +} + +// createStringToSign build the need to be signed string +func createStringToSign(canonicalRequest string, signParams reqSignParams) string { + return strings.Join([]string{signParams.SignAlgorithm, + signParams.getFormattedSigningDateTime(), + signParams.getScope(), + hex.EncodeToString(HashSha256([]byte(canonicalRequest))), + }, "\n") +} + +// getCanonicalizedResourcePath builds the valid url path for signing +func getCanonicalizedResourcePath(signParas reqSignParams) string { + urlStr := signParas.Req.URL.Path + if !strings.HasPrefix(urlStr, "/") { + urlStr = "/" + urlStr + } + + if !strings.HasSuffix(urlStr, "/") { + urlStr = urlStr + "/" + } + + if signParas.encodeUrl { + urlStr = urlEncode(urlStr, true) + } + + if urlStr == "" { + urlStr = "/" + } + + return urlStr +} + +// urlEncode encodes url path and url querystring according to the following rules: +// The alphanumeric characters "a" through "z", "A" through "Z" and "0" through "9" remain the same. +//The special characters ".", "-", "*", and "_" remain the same. +//The space character " " is converted into a plus sign "%20". +//All other characters are unsafe and are first converted into one or more bytes using some encoding scheme. +func urlEncode(url string, urlPath bool) string { + var buf bytes.Buffer + for i := 0; i < len(url); i++ { + c := url[i] + if noEscape[c] || (c == '/' && urlPath) { + buf.WriteByte(c) + } else { + fmt.Fprintf(&buf, "%%%02X", c) + } + } + + return buf.String() +} + +// encodeQueryString build and encode querystring to a string for signing +func encodeQueryString(queryValues url.Values) string { + var encodedVals = make(map[string]string, len(queryValues)) + var keys = make([]string, len(queryValues)) + + i := 0 + + for k, _ := range queryValues { + keys[i] = urlEncode(k, false) + encodedVals[keys[i]] = k + i++ + } + + caseInsensitiveSort(keys) + + var queryStr StringBuilder + for i, k := range keys { + if i > 0 { + queryStr.Write("&") + } + + queryStr.Write(k).Write("=").Write(urlEncode(queryValues.Get(encodedVals[k]), false)) + } + + return queryStr.ToString() +} + +// getCanonicalizedQueryString return empty string if in POST method and content is nil, otherwise returns sorted,encoded querystring +func getCanonicalizedQueryString(signParas reqSignParams) string { + if usePayloadForQueryParameters(signParas.Req) { + return "" + } else { + return encodeQueryString(signParas.Req.URL.Query()) + } +} + +// createCanonicalRequest builds canonical string depends the official document for signing +func createCanonicalRequest(signParas reqSignParams, contentSha256 string) string { + return strings.Join([]string{signParas.Req.Method, + getCanonicalizedResourcePath(signParas), + getCanonicalizedQueryString(signParas), + getCanonicalizedHeaderString(signParas.Req), + getSignedHeadersString(signParas.Req), + contentSha256, + }, "\n") +} + +// calculateContentHash computes the content hash value +func calculateContentHash(req *http.Request) string { + encodeParas := "" + + //post and content is null use queryString as content -- according to document + if usePayloadForQueryParameters(req) { + encodeParas = req.URL.Query().Encode() + } else { + if req.Body == nil { + encodeParas = "" + } else { + readBody, _ := ioutil.ReadAll(req.Body) + req.Body = ioutil.NopCloser(bytes.NewBuffer(readBody)) + encodeParas = string(readBody) + } + } + + return hex.EncodeToString(HashSha256([]byte(encodeParas))) +} + +// usePayloadForQueryParameters specifies use querystring or not as content for compute content hash +func usePayloadForQueryParameters(req *http.Request) bool { + if strings.ToLower(req.Method) != "post" { + return false + } + + return req.Body == nil +} + +// getCanonicalizedHeaderString converts header map to a string for signing +func getCanonicalizedHeaderString(req *http.Request) string { + var headers StringBuilder + + keys := make([]string, 0) + for k, _ := range req.Header { + keys = append(keys, strings.TrimSpace(k)) + } + + caseInsensitiveSort(keys) + + for _, k := range keys { + k = strings.ToLower(k) + newKey := spaceRegexp.ReplaceAllString(k, " ") + headers.Write(newKey) + headers.Write(":") + + val := req.Header.Get(k) + val = spaceRegexp.ReplaceAllString(val, " ") + headers.Write(val) + + headers.Write("\n") + } + + return headers.ToString() +} + +// getSignedHeadersString builds the string for AuthorizationHeader and signing +func getSignedHeadersString(req *http.Request) string { + var headers StringBuilder + + keys := make([]string, 0) + for k, _ := range req.Header { + keys = append(keys, strings.TrimSpace(k)) + } + + caseInsensitiveSort(keys) + + for idx, k := range keys { + + if idx > 0 { + headers.Write(";") + } + + headers.Write(strings.ToLower(k)) + } + + return headers.ToString() +} + +// addRequiredHeaders adds the required heads to http.request instance +func addRequiredHeaders(req *http.Request, timeStr string) { + // golang handls port by default + req.Header.Add("Host", req.URL.Host) + req.Header.Add("X-Sdk-Date", timeStr) +} + +func (s caseInsencitiveStringArray) Len() int { + return len(s) +} +func (s caseInsencitiveStringArray) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} +func (s caseInsencitiveStringArray) Less(i, j int) bool { + return strings.ToLower(s[i]) < strings.ToLower(s[j]) +} + +func caseInsensitiveSort(strSlice []string) { + sort.Sort(caseInsencitiveStringArray(strSlice)) +} + +func (signParas *reqSignParams) getSigningDateTimeMilli() int64 { + return (signParas.RequestTime.UTC().Unix() - signParas.TimeOffsetInseconds) * 1000 +} + +func (signParas *reqSignParams) getSigningDateTime() time.Time { + return time.Unix(signParas.getSigningDateTimeMilli()/1000, 0) +} + +func (signParas *reqSignParams) getDaysSinceEpon() int64 { + return signParas.getSigningDateTimeMilli() / 1000 / 3600 / 24 +} + +func (signParas *reqSignParams) getFormattedSigningDate() string { + return signParas.getSigningDateTime().UTC().Format("20060102") +} +func (signParas *reqSignParams) getFormattedSigningDateTime() string { + return signParas.getSigningDateTime().UTC().Format("20060102T150405Z") +} + +func (signParas *reqSignParams) getScope() string { + return strings.Join([]string{signParas.getFormattedSigningDate(), + signParas.RegionName, + signParas.ServiceName, + "sdk_request", + }, "/") +} + +func (buff *StringBuilder) Write(s string) *StringBuilder { + buff.builder.WriteString((s)) + return buff +} + +func (buff *StringBuilder) ToString() string { + return buff.builder.String() +} + +func (buff *StringBuilder) GetBytes() []byte { + return []byte(buff.ToString()) +}