From 657e32bbd4b8e7d807a4870cd1317afcefe4bd88 Mon Sep 17 00:00:00 2001 From: arkxu Date: Tue, 3 May 2016 01:08:56 -0700 Subject: [PATCH] add keystone middleware for token validation Change-Id: If3478342eaafbb61e6f99841d4930b9858dd23ac --- identity/middleware/types.go | 46 ++++ identity/middleware/validation.go | 345 +++++++++++++++++++++++++ identity/middleware/validation_test.go | 87 +++++++ openstack/auth.go | 6 +- 4 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 identity/middleware/types.go create mode 100644 identity/middleware/validation.go create mode 100644 identity/middleware/validation_test.go diff --git a/identity/middleware/types.go b/identity/middleware/types.go new file mode 100644 index 0000000..7aa880c --- /dev/null +++ b/identity/middleware/types.go @@ -0,0 +1,46 @@ +// Copyright (c) 2016 eBay Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package middleware + +import ( + "time" + + "git.openstack.org/openstack/golang-client.git/openstack" +) + +type Validator struct { + // Service account to talk to keystone + SvcAuthOpts openstack.AuthOpts + // File path the signing cert would be stored/cached + CachedSigningKeyPath string + TokenId string + // Token revocation list memory cache duration + RevCacheDuration time.Duration +} + +// Token revocation response structure +type revokeResp struct { + Signed string `json:"signed"` +} + +type revokedListCache struct { + Revoked []openstack.Token + // time when this cache was built + Time time.Time +} + +type RevokedList struct { + Revoked []openstack.Token +} diff --git a/identity/middleware/validation.go b/identity/middleware/validation.go new file mode 100644 index 0000000..1f8d54c --- /dev/null +++ b/identity/middleware/validation.go @@ -0,0 +1,345 @@ +// Copyright (c) 2016 eBay Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package middleware + +import ( + "bytes" + "compress/zlib" + "crypto/md5" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "strings" + "time" + + "git.openstack.org/openstack/golang-client.git/openstack" + "github.com/fullsailor/pkcs7" +) + +const ( + PKI_ASN1_PREFIX = "MII" + PKIZ_PREFIX = "PKIZ_" +) + +// Cache the token until it gets expired +var serviceTokenSession *openstack.Session + +// Cache token revocation list until expired +var revocationListCache *revokedListCache + +// NewValidator gets the credential for service account, token need to be validated, +// signing cert location (will store the cert from keystone if not there), and the +// revocation list cache duration (in seconds) and returns the validator. +func NewValidator(authOpts openstack.AuthOpts, token string, signingKeyPath string, revCacheSecs int) *Validator { + return &Validator{ + SvcAuthOpts: authOpts, + CachedSigningKeyPath: signingKeyPath, + TokenId: token, + RevCacheDuration: time.Duration(revCacheSecs) * time.Second, + } +} + +// Validate does the local validation for PKI & PKIZ token and sends to keystone +// for other format tokens validation. It returns the extracted AuthToken struct +func (validator *Validator) Validate() (*openstack.AuthToken, error) { + var token *openstack.AuthToken + + if strings.HasPrefix(validator.TokenId, PKIZ_PREFIX) || + strings.HasPrefix(validator.TokenId, PKI_ASN1_PREFIX) { + // do local validation for PKI and PKIZ token + if revocationListCache == nil || time.Now().Sub(revocationListCache.Time) > validator.RevCacheDuration { + _, err := validator.getRevocationList() + if err != nil { + return nil, err + } + } + access, err := validator.ValidateOffline() + if err != nil { + return nil, err + } + + if err = json.Unmarshal(access, &token); err != nil { + return nil, err + } + + // set the token ID + token.Access.Token.ID = validator.TokenId + + hashedTokenId := fmt.Sprintf("%x", md5.Sum([]byte(validator.TokenId))) + for _, rtoken := range revocationListCache.Revoked { + if rtoken.ID == hashedTokenId { + return nil, fmt.Errorf("token %s was revoked", hashedTokenId) + } + } + } else { + access, err := validator.ValidateRemote() + if err != nil { + return nil, err + } + + if err = json.Unmarshal(access, &token); err != nil { + return nil, err + } + } + + // validation should fail if token is expired + if token.Access.Token.Expires.Sub(time.Now()) < 0 { + return nil, fmt.Errorf("token %s is expired", validator.TokenId) + } + return token, nil +} + +// Validate the token locally without sending to keystone +// It take the token body and return the extracted access object as []byte +func (validator *Validator) ValidateOffline() ([]byte, error) { + token := "" + switch { + case strings.HasPrefix(validator.TokenId, PKIZ_PREFIX): + token = strings.TrimPrefix(validator.TokenId, PKIZ_PREFIX) + decompressedToken, err := decompressToken(token) + if err != nil { + return nil, err + } + + token = trimCMSFormat(decompressedToken) + case strings.HasPrefix(validator.TokenId, PKI_ASN1_PREFIX): + token = validator.TokenId + + default: + return nil, errors.New("can not validate offline, it has to be sent to keystone") + } + + decodedToken, err := base64DecodeFromCms(token) + if err != nil { + return nil, err + } + + content, err := validator.checkSignature(decodedToken) + if err != nil { + return nil, err + } + return content, nil +} + +func (validator *Validator) ValidateRemote() ([]byte, error) { + resp, err := validator.reqTokenValidation() + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusUnauthorized { + // retry one more time using the new service token + err = validator.renewToken() + if err != nil { + return nil, err + } + resp, err = validator.reqTokenValidation() + if err != nil { + return nil, err + } + } + + rbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, errors.New("error reading response body") + } + return rbody, nil +} + +func decompressToken(token string) (string, error) { + decToken, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return "", err + } + + zr, err := zlib.NewReader(bytes.NewBuffer(decToken)) + if err != nil { + return "", err + } + bb, err := ioutil.ReadAll(zr) + if err != nil { + return "", err + } + return string(bb), nil +} + +func base64DecodeFromCms(token string) ([]byte, error) { + t := strings.Replace(token, "-", "/", -1) + decToken, err := base64.StdEncoding.DecodeString(t) + if err != nil { + return nil, err + } + return decToken, nil +} + +// remove the customerized header and footer in PEM token +// -----BEGIN CMS----- +// -----END CMS----- +func trimCMSFormat(token string) string { + l := strings.Index(token, "\n") + r := strings.LastIndex(token, "\n") + t := token[l:r] + r2 := strings.LastIndex(t, "\n") + return t[0:r2] +} + +// Get the signging certificate from local dir +// It will get the cert from keystone if cache file does not exist +func (validator *Validator) getSigningCert() (*x509.Certificate, error) { + signPEM, err := ioutil.ReadFile(validator.CachedSigningKeyPath) + if err != nil { + resp, err := http.Get(validator.SvcAuthOpts.AuthUrl + "/certificates/signing") + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, errors.New("can not get signing cert") + } + signPEM, err = ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + // cache the file to location + if err = ioutil.WriteFile(validator.CachedSigningKeyPath, []byte(signPEM), 0644); err != nil { + log.Println("error caching signging cert") + } + } + + block, _ := pem.Decode(signPEM) + if block == nil { + return nil, errors.New("can not decode PEM") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + return cert, nil +} + +// check the signature of the token +func (validator *Validator) checkSignature(data []byte) ([]byte, error) { + p7, err := pkcs7.Parse(data) + if err != nil { + return nil, err + } + + if len(p7.Signers) != 1 { + return nil, errors.New("should be only one signature found") + } + + signer := p7.Signers[0] + cert, err := validator.getSigningCert() + if err != nil { + return nil, err + } + + err = cert.CheckSignature(x509.SHA256WithRSA, p7.Content, signer.EncryptedDigest) + if err != nil { + return nil, err + } + + return p7.Content, nil +} + +// getRevocationList get a list of revoked tokens +func (validator *Validator) getRevocationList() ([]openstack.Token, error) { + // Get the service token to get the token revocation list + resp, err := validator.reqRevocationList() + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusUnauthorized { + // try again when getting 401, it could be the cached token was revoked + // or the token got expired + validator.renewToken() + resp, err = validator.reqRevocationList() + if err != nil { + return nil, err + } + } + + rbody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + revokeCMSResp := revokeResp{} + if err = json.Unmarshal(rbody, &revokeCMSResp); err != nil { + return nil, err + } + + revokeResp := trimCMSFormat(revokeCMSResp.Signed) + decodedResp, err := base64DecodeFromCms(revokeResp) + if err != nil { + return nil, err + } + + revoked, err := validator.checkSignature(decodedResp) + if err != nil { + return nil, err + } + revokedList := RevokedList{} + if err = json.Unmarshal(revoked, &revokedList); err != nil { + return nil, err + } + + // update the revocation list cache + revocationListCache = &revokedListCache{ + Revoked: revokedList.Revoked, + Time: time.Now(), + } + return revokedList.Revoked, nil +} + +// reqRevocationList sends the GET request to keystone and get response back +func (validator *Validator) reqRevocationList() (*http.Response, error) { + if serviceTokenSession == nil { + validator.renewToken() + } + // Get token revocation list + return serviceTokenSession.Request("GET", validator.SvcAuthOpts.AuthUrl+"/tokens/revoked", nil, nil, nil) +} + +func (validator *Validator) reqTokenValidation() (*http.Response, error) { + if serviceTokenSession == nil { + validator.renewToken() + } + // Get token revocation list + reqUrl := fmt.Sprintf("%s/tokens/%s", validator.SvcAuthOpts.AuthUrl, validator.TokenId) + return serviceTokenSession.Request("GET", reqUrl, nil, nil, nil) +} + +// renewToken gets the keystone token from service AuthOpts +func (validator *Validator) renewToken() error { + // Get the service token to get the token revocation list + auth, err := openstack.DoAuthRequest(validator.SvcAuthOpts) + if err != nil { + return err + } + + // Make a new client with these creds + serviceTokenSession, err = openstack.NewSession(nil, auth, nil) + if err != nil { + return err + } + return nil +} diff --git a/identity/middleware/validation_test.go b/identity/middleware/validation_test.go new file mode 100644 index 0000000..5815173 --- /dev/null +++ b/identity/middleware/validation_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2016 eBay Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package middleware + +import ( + "fmt" + "testing" + "time" + + "git.openstack.org/openstack/golang-client.git/openstack" +) + +const ( + cacheKeyPath = "/tmp/signing.PEM" +) + +func getAuthOpts() openstack.AuthOpts { + return openstack.AuthOpts{ + AuthUrl: "http://localhost:5000/v2.0", + Project: "demo", + Username: "demo", + Password: "demo", + } +} + +func TestVerifyLocalPKIZ(t *testing.T) { + token := `PKIZ_eJxdVduSqjoQfecrzvvUruEijj6GWwRNGBQC4U3AgXDTGVQuX78jzpy9z7EqVdJ091qru9P8-sV_mglt_I-ODo-HXwKybVMB1NmeY7u4pxh4qX7mtsnXdQ1VOvDMwfDBTsvrvKhyLfaQCXKzz3P0qeXVp1BUDK57UdN6mHNjotm2YSkdoyEu4whPdnnOT6MjJvK1Tpi9tDcdO3J7FpIqki1ReDjYpd0jn16REfCDA-RXS-yDJfLzG_bNHk25bLM-txuySGE9xhFiLgMMlWAQMBN7fODH8gbXP0vIOA-udWY7HbBj6M2OKRwKKpNzIg9VHNnzuwySGw3VJwNOsUyUZ9YYkm-0fZ3KeDxGmngM17eHX9KSLtFnJjzY6WIOILhME9OW1H8jIogGXCLV9R1GZVxjIxjiMhfxxOX4cY1CU0E-EF0jZ8JMp9XKuRYcNWN2Z-sOR5DqH1sSrtjH4YFsSTGsxUiuq3TkBbU6JiCzGo6lLWOlPtBgJXlS3PpV7RFLI9noZHNgi-u0jS-8DgaN9kUC1-0jeci7JcShWqVwfUna_fRjpOH-egxVElT9LOm42Yupcb7v5OFOZas7wvWUGecJ-0gS8LiWEVv0c-Fap47l-j73W3emDFptfJgl9Rkv2jHE_yZ9FrquvucAVxjihvrBSENPdn1voBNhbkhqPKUqLrMKTUhxDYvZrZQ96vbDXHhQj-ThkjTXpwRJfCCKp0j7LmJdcWfxGO3VJ6paJGHwlCYTVUiV_T1pAvZB5kAplcn4PRvz_6SxrlzGQxJPJN2_pd4TSGZg4X_Il1h_ONsTLp2Kyp6MIZVjvxJx6Sm01Gpc1txuL1wDl3SyJ8HV5yKNfEzrZ2-vb3_T9ENypQ1n1XIAfpH-YlTEUOIMmLOODv_Vxsf5mozO-lGsJCQib2uRQZO_03jymlO3qiSs5_EWkP5H0yxltNW4Cca4oWocBiK_iSItzYXr7wvXBws8FRWSAxU1TkH51AqcSoNCXGCjYLGRirFh87OvKQ_AvtfTho7I8HgSr0c6n8xIWiNqawVfOICausA3Th8ZWoS8rtc9ahDPg2bvkGAyXQR6CKTA1EFvkVCd4sgbjBIgLcdEAynSCLkJvFUi8ha9AebgrQEISfjsZ4ZJkUbnBKDv3zMlU3Z8ofBL1icwuFF5feWrTRMQ6KAe6vAArBr0ng58gB-77mfVcYYWAAe-G_WCnrqRmp_Wockkpwx39CV0BZvhXLZ2b2R4lWm1ircIOjp6Z8Dp9Oh4wtsV7PL0vicvm3fn86gV7eErk3R7a42bdukHggOOh6F6Wb1btCPZdoViT_psEaSAeY6rh4ssjr6KF602S_08sZ7mUA6i5uYaBdRongkrkOpKkOUsqqRop1b5eXz9APS1BRejWYr4a2dU4qbQGnyzbu5YFiT3P5rUNUv1w98ub4L0vowcxdJEiymnfLOXqvtp97rb7_F60jbs6y2kfgmvqvylti8Ef8rvO3OZv3pfLBET1r0IoyeudBj24IK2NLSNKhmUj9gRr_gmS6feGRdLaVGSLdhWa2Le3tCFdxePi1twmNz3ggnz98nExp9v1W82DTmf` + validator := NewValidator(getAuthOpts(), token, cacheKeyPath, 6) + access, err := validator.Validate() + if err != nil { + t.Fatal(err) + } + fmt.Println(access) + project := access.Access.Token.Project + if project.Name != "demo" { + t.Fail() + } +} + +func TestVerifyLocalPKI(t *testing.T) { + token := `MIIE3AYJKoZIhvcNAQcCoIIEzTCCBMkCAQExDTALBglghkgBZQMEAgEwggMqBgkqhkiG9w0BBwGgggMbBIIDF3siYWNjZXNzIjogeyJ0b2tlbiI6IHsiaXNzdWVkX2F0IjogIjIwMTYtMDUtMDNUMTk6NTE6MTIuNTM1NzQzIiwgImV4cGlyZXMiOiAiMjAxNi0wNS0wNFQxOTo1MToxMloiLCAiaWQiOiAicGxhY2Vob2xkZXIiLCAidGVuYW50IjogeyJjb3MiOiAiZGV2IiwgImRlc2NyaXB0aW9uIjogbnVsbCwgImVuYWJsZWQiOiB0cnVlLCAiaWQiOiAiMGMxNjM5OTJiY2NlNDUxZjg0NzEwMTZlMWE3MTA0ODgiLCAidnBjIjogImRldiIsICJuYW1lIjogImRlbW8ifSwgImF1ZGl0X2lkcyI6IFsiS01WV3F1U3NSYkdaTEc1Q0E2YnE2ZyJdfSwgInNlcnZpY2VDYXRhbG9nIjogW3siZW5kcG9pbnRzIjogW3siYWRtaW5VUkwiOiAiaHR0cDovL2xvY2FsaG9zdDozNTM1Ny92Mi4wIiwgInJlZ2lvbiI6ICJzdGFnZSIsICJwdWJsaWNVUkwiOiAiIiwgImlkIjogIjNkNGNmYTUyYWQ2OTQxYzViOWVlNzc5NjdkMzM3ODFiIn1dLCAiZW5kcG9pbnRzX2xpbmtzIjogW10sICJ0eXBlIjogImlkZW50aXR5IiwgIm5hbWUiOiAia2V5c3RvbmUifV0sICJ1c2VyIjogeyJ1c2VybmFtZSI6ICJkZW1vIiwgInJvbGVzX2xpbmtzIjogW10sICJpZCI6ICIzNjJkY2Q2NGY2ZTk0NjQ3YjBlNjlkY2I4ODNjYzIzOCIsICJyb2xlcyI6IFt7Im5hbWUiOiAiTWVtYmVyIn0sIHsibmFtZSI6ICJhZG1pbiJ9XSwgIm5hbWUiOiAiZGVtbyJ9LCAibWV0YWRhdGEiOiB7ImlzX2FkbWluIjogMCwgInJvbGVzIjogWyI5ZmUyZmY5ZWU0Mzg0YjE4OTRhOTA4NzhkM2U5MmJhYiIsICJmMWNhNDhiZDc0ZDI0ZDRlYTRhNTQwYmYyMDQ0YjQwMCJdfX19MYIBhTCCAYECAQEwXDBXMQswCQYDVQQGEwJVUzEOMAwGA1UECAwFVW5zZXQxDjAMBgNVBAcMBVVuc2V0MQ4wDAYDVQQKDAVVbnNldDEYMBYGA1UEAwwPd3d3LmV4YW1wbGUuY29tAgEBMAsGCWCGSAFlAwQCATANBgkqhkiG9w0BAQEFAASCAQBKR9e1K9TYOanIHpoxpCwKjnFY1Ue66+GbKVr956TLA+d2Q82IS-vJpmGRdxZh0t05knoErJEwjaq2XtA2subfIPnX6zm34y6Q1f8AJXDUowWX8YeeyRs548oCdaHoE1ak81jGOzYjMhZc-kljUlEDE4ejlO4wkxCnagDiA7uaRJgmSzB2kuuKZeeMxhlTe78tkoco3a1gZCGjGsUuEzbH5HU6RSugI5uxUGyMW0PS2j4K2+BBq2Uk-nHX0pIb513NOoDZztVq6ZuYx3KPIe-h29IMzoqL9OcZ4JH49ehzlDlTw8otu8wS8JUaIv7HNnGgJbCbsUmQOPvWju89rB3k` + validator := NewValidator(getAuthOpts(), token, cacheKeyPath, 6) + access, err := validator.Validate() + if err != nil { + t.Fatal(err) + } + fmt.Println(access) + project := access.Access.Token.Project + if project.Name != "demo" { + t.Fail() + } +} + +func TestVerifyLocalUUID(t *testing.T) { + token := `399789012f4fbedc63c55396f59654d6` + validator := NewValidator(getAuthOpts(), token, cacheKeyPath, 120) + access, err := validator.Validate() + if err != nil { + t.Fatal(err) + } + fmt.Println(access) + project := access.Access.Token.Project + if project.Name != "demo" { + t.Fail() + } +} + +// Should be half traffic sending to keystone, not every time +func TestCache(t *testing.T) { + for i := 0; i < 4; i++ { + TestVerifyLocalPKIZ(t) + TestVerifyLocalPKI(t) + time.Sleep(3 * time.Second) + } +} diff --git a/openstack/auth.go b/openstack/auth.go index ed4c78e..d73cae3 100644 --- a/openstack/auth.go +++ b/openstack/auth.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io/ioutil" + "net/http" "strings" "time" ) @@ -78,7 +79,10 @@ func DoAuthRequest(authopts AuthOpts) (AuthRef, error) { path := auth_mod.AuthUrl + "/tokens" body := auth_mod.JSON() - resp, err := Post(path, nil, nil, &body) + headers := &http.Header{} + headers.Add("Content-Type", "application/json") + + resp, err := Post(path, nil, headers, &body) if err != nil { return nil, err }