add keystone middleware for token validation

Change-Id: If3478342eaafbb61e6f99841d4930b9858dd23ac
This commit is contained in:
arkxu 2016-05-03 01:08:56 -07:00 committed by Fangzhou Xu
parent 16ac1f84b2
commit 657e32bbd4
4 changed files with 483 additions and 1 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}