diff --git a/pkg/app/api/apikeyverifier/BUILD.bazel b/pkg/app/api/apikeyverifier/BUILD.bazel new file mode 100644 index 0000000000..adc9efa642 --- /dev/null +++ b/pkg/app/api/apikeyverifier/BUILD.bazel @@ -0,0 +1,28 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["verifier.go"], + importpath = "github.com/pipe-cd/pipe/pkg/app/api/apikeyverifier", + visibility = ["//visibility:public"], + deps = [ + "//pkg/cache:go_default_library", + "//pkg/cache/memorycache:go_default_library", + "//pkg/model:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + size = "small", + srcs = ["verifier_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/model:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@org_golang_google_protobuf//proto:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/pkg/app/api/apikeyverifier/verifier.go b/pkg/app/api/apikeyverifier/verifier.go new file mode 100644 index 0000000000..5a7bddeb02 --- /dev/null +++ b/pkg/app/api/apikeyverifier/verifier.go @@ -0,0 +1,90 @@ +// Copyright 2020 The PipeCD Authors. +// +// 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 apikeyverifier + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + + "github.com/pipe-cd/pipe/pkg/cache" + "github.com/pipe-cd/pipe/pkg/cache/memorycache" + "github.com/pipe-cd/pipe/pkg/model" +) + +type apiKeyGetter interface { + GetAPIKey(ctx context.Context, id string) (*model.APIKey, error) +} + +type Verifier struct { + apiKeyCache cache.Cache + apiKeyStore apiKeyGetter + logger *zap.Logger +} + +func NewVerifier(ctx context.Context, getter apiKeyGetter, logger *zap.Logger) *Verifier { + return &Verifier{ + apiKeyCache: memorycache.NewTTLCache(ctx, 5*time.Minute, time.Minute), + apiKeyStore: getter, + logger: logger, + } +} + +func (v *Verifier) Verify(ctx context.Context, key string) (*model.APIKey, error) { + keyID, err := model.ExtractAPIKeyID(key) + if err != nil { + return nil, err + } + + var apiKey *model.APIKey + item, err := v.apiKeyCache.Get(keyID) + if err == nil { + apiKey = item.(*model.APIKey) + if err := checkAPIKey(apiKey, keyID, key); err != nil { + return nil, err + } + return apiKey, nil + } + + // If the cache data was not found, + // we have to retrieve from datastore and save it to the cache. + apiKey, err = v.apiKeyStore.GetAPIKey(ctx, keyID) + if err != nil { + return nil, fmt.Errorf("unable to find API key %s from datastore, %w", keyID, err) + } + + if err := v.apiKeyCache.Put(keyID, apiKey); err != nil { + v.logger.Warn("unable to store API key in memory cache", zap.Error(err)) + } + if err := checkAPIKey(apiKey, keyID, key); err != nil { + return nil, err + } + + return apiKey, nil +} + +func checkAPIKey(apiKey *model.APIKey, id, key string) error { + if apiKey.Disabled { + return fmt.Errorf("the api key %s was already disabled", id) + } + + if err := apiKey.CompareKey(key); err != nil { + return fmt.Errorf("invalid api key %s: %w", id, err) + } + + return nil +} diff --git a/pkg/app/api/apikeyverifier/verifier_test.go b/pkg/app/api/apikeyverifier/verifier_test.go new file mode 100644 index 0000000000..68b0cf3dc4 --- /dev/null +++ b/pkg/app/api/apikeyverifier/verifier_test.go @@ -0,0 +1,105 @@ +// Copyright 2020 The PipeCD Authors. +// +// 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 apikeyverifier + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" + + "github.com/pipe-cd/pipe/pkg/model" +) + +type fakeAPIKeyGetter struct { + calls int + apiKeys map[string]*model.APIKey +} + +func (g *fakeAPIKeyGetter) GetAPIKey(_ context.Context, id string) (*model.APIKey, error) { + g.calls++ + p, ok := g.apiKeys[id] + if ok { + msg := proto.Clone(p) + return msg.(*model.APIKey), nil + } + return nil, fmt.Errorf("not found") +} + +func TestVerify(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var id1 = "test-api-key" + key1, hash1, err := model.GenerateAPIKey(id1) + require.NoError(t, err) + + var id2 = "disabled-api-key" + key2, hash2, err := model.GenerateAPIKey(id2) + require.NoError(t, err) + + apiKeyGetter := &fakeAPIKeyGetter{ + apiKeys: map[string]*model.APIKey{ + id1: { + Id: id1, + Name: id1, + KeyHash: hash1, + ProjectId: "test-project", + }, + id2: { + Id: id2, + Name: id2, + KeyHash: hash2, + ProjectId: "test-project", + Disabled: true, + }, + }, + } + v := NewVerifier(ctx, apiKeyGetter, zap.NewNop()) + + // Not found key. + notFoundKey, _, err := model.GenerateAPIKey("not-found-api-key") + require.NoError(t, err) + + apiKey, err := v.Verify(ctx, notFoundKey) + require.Nil(t, apiKey) + require.NotNil(t, err) + assert.Equal(t, "unable to find API key not-found-api-key from datastore, not found", err.Error()) + require.Equal(t, 1, apiKeyGetter.calls) + + // Found key but it was disabled. + apiKey, err = v.Verify(ctx, key2) + require.Nil(t, apiKey) + require.NotNil(t, err) + assert.Equal(t, "the api key disabled-api-key was already disabled", err.Error()) + require.Equal(t, 2, apiKeyGetter.calls) + + // Found key but invalid secret. + apiKey, err = v.Verify(ctx, fmt.Sprintf("%s.invalidhash", id1)) + require.Nil(t, apiKey) + require.NotNil(t, err) + assert.Equal(t, "invalid api key test-api-key: wrong api key test-api-key.invalidhash: crypto/bcrypt: hashedPassword is not the hash of the given password", err.Error()) + require.Equal(t, 3, apiKeyGetter.calls) + + // OK. + apiKey, err = v.Verify(ctx, key1) + assert.Equal(t, id1, apiKey.Name) + assert.Nil(t, err) + require.Equal(t, 3, apiKeyGetter.calls) +} diff --git a/pkg/model/apikey.go b/pkg/model/apikey.go index 3326ac1126..d9c996782b 100644 --- a/pkg/model/apikey.go +++ b/pkg/model/apikey.go @@ -23,7 +23,7 @@ import ( ) const ( - apiKeyLength = 50 + apiKeyLength = 32 ) func GenerateAPIKey(id string) (key, hash string, err error) { diff --git a/pkg/rpc/rpcauth/BUILD.bazel b/pkg/rpc/rpcauth/BUILD.bazel index a0c9565f24..f34e2cdb40 100644 --- a/pkg/rpc/rpcauth/BUILD.bazel +++ b/pkg/rpc/rpcauth/BUILD.bazel @@ -29,7 +29,9 @@ go_test( ], embed = [":go_default_library"], deps = [ + "//pkg/model:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", "@org_golang_google_grpc//:go_default_library", "@org_golang_google_grpc//metadata:go_default_library", "@org_uber_go_zap//:go_default_library", diff --git a/pkg/rpc/rpcauth/auth.go b/pkg/rpc/rpcauth/auth.go index 21b822dd30..a5e711ddf8 100644 --- a/pkg/rpc/rpcauth/auth.go +++ b/pkg/rpc/rpcauth/auth.go @@ -30,13 +30,14 @@ type CredentialsType string const ( // IDTokenCredentials represents JWT IDToken for a web user. - // They can be used for project admin, project viewer or owner. IDTokenCredentials CredentialsType = "ID-TOKEN" - // PipedTokenCredentials represents a generated token for authenticating - // between Piped and microservices. + // PipedTokenCredentials represents a generated token for + // authenticating between Piped and control-plane. PipedTokenCredentials CredentialsType = "PIPED-TOKEN" + // APIKeyCredentials represents a generated key for + // authenticating between pipectl/external-service and control-plane. + APIKeyCredentials CredentialsType = "API-KEY" // UnknownCredentials represents an unsupported credentials. - // It is used as a return result in case of error. UnknownCredentials CredentialsType = "UNKNOWN" ) @@ -84,26 +85,36 @@ func extractCredentials(ctx context.Context) (creds Credentials, err error) { err = status.Error(codes.Unauthenticated, "missing credentials") return } + rawCredentials := md["authorization"] if len(rawCredentials) == 0 { err = status.Error(codes.Unauthenticated, "missing credentials in authorization") return } + subs := strings.Split(rawCredentials[0], " ") if len(subs) != 2 { err = status.Error(codes.Unauthenticated, "credentials is malformed") return } + switch CredentialsType(subs[0]) { case IDTokenCredentials: creds.Data = subs[1] creds.Type = IDTokenCredentials + case PipedTokenCredentials: creds.Data = subs[1] creds.Type = PipedTokenCredentials + + case APIKeyCredentials: + creds.Data = subs[1] + creds.Type = APIKeyCredentials + default: err = status.Error(codes.Unauthenticated, "unsupported credentials type") } + if creds.Data == "" { err = status.Error(codes.Unauthenticated, "credentials is malformed") } diff --git a/pkg/rpc/rpcauth/auth_test.go b/pkg/rpc/rpcauth/auth_test.go index b87a58bd44..878b9abdd8 100644 --- a/pkg/rpc/rpcauth/auth_test.go +++ b/pkg/rpc/rpcauth/auth_test.go @@ -82,6 +82,15 @@ func TestExtractToken(t *testing.T) { expectedCredentialsType: PipedTokenCredentials, failed: false, }, + { + name: "should be ok with APIKey", + ctx: metadata.NewIncomingContext(context.Background(), metadata.MD{ + "authorization": []string{"API-KEY key"}, + }), + expectedCredentials: "key", + expectedCredentialsType: APIKeyCredentials, + failed: false, + }, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { diff --git a/pkg/rpc/rpcauth/interceptor.go b/pkg/rpc/rpcauth/interceptor.go index 9bcc63e89b..84732a7bb0 100644 --- a/pkg/rpc/rpcauth/interceptor.go +++ b/pkg/rpc/rpcauth/interceptor.go @@ -37,11 +37,16 @@ type RBACAuthorizer interface { Authorize(string, model.Role) bool } -// PipedTokenVerifier defines a function to check piped token. +// PipedTokenVerifier verifies the given piped token. type PipedTokenVerifier interface { Verify(ctx context.Context, projectID, pipedID, pipedKey string) error } +// APIKeyVerifier verifies the given API key. +type APIKeyVerifier interface { + Verify(ctx context.Context, key string) (*model.APIKey, error) +} + type ( claimsContextKey struct{} pipedTokenContextKey struct{} @@ -50,11 +55,13 @@ type ( PipedID string PipedKey string } + apiKeyContextKey struct{} ) var ( claimsKey = claimsContextKey{} pipedTokenKey = pipedTokenContextKey{} + apiKeyKey = apiKeyContextKey{} ) // PipedTokenUnaryServerInterceptor extracts credentials from gRPC metadata @@ -125,6 +132,51 @@ func PipedTokenStreamServerInterceptor(verifier PipedTokenVerifier, logger *zap. } } +// ExtractPipedToken returns the verified piped key inside a given context. +func ExtractPipedToken(ctx context.Context) (projectID, pipedID, pipedKey string, err error) { + v, ok := ctx.Value(pipedTokenKey).(pipedTokenContextValue) + if !ok { + err = errUnauthenticated + return + } + projectID = v.ProjectID + pipedID = v.PipedID + pipedKey = v.PipedKey + return +} + +// APIKeyUnaryServerInterceptor extracts credentials from gRPC metadata +// and validates it by the specified Verifier. +// The valid API key will be set to the context. +func APIKeyUnaryServerInterceptor(verifier APIKeyVerifier, logger *zap.Logger) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + creds, err := extractCredentials(ctx) + if err != nil { + return nil, err + } + if creds.Type != APIKeyCredentials { + logger.Warn("wrong credentials type for APIKeyCredentials", zap.Any("credentials", creds)) + return nil, errUnauthenticated + } + apiKey, err := verifier.Verify(ctx, creds.Data) + if err != nil { + logger.Warn("unable to verify api key", zap.Error(err)) + return nil, errUnauthenticated + } + ctx = context.WithValue(ctx, apiKeyKey, apiKey) + return handler(ctx, req) + } +} + +// ExtractAPIKey returns the verified API key inside the given context. +func ExtractAPIKey(ctx context.Context) (*model.APIKey, error) { + k, ok := ctx.Value(apiKeyKey).(*model.APIKey) + if !ok { + return nil, errUnauthenticated + } + return k, nil +} + // JWTUnaryServerInterceptor ensures that the JWT credentials included in the context // must be verified by verifier. func JWTUnaryServerInterceptor(verifier jwt.Verifier, authorizer RBACAuthorizer, logger *zap.Logger) grpc.UnaryServerInterceptor { @@ -155,19 +207,6 @@ func JWTUnaryServerInterceptor(verifier jwt.Verifier, authorizer RBACAuthorizer, } } -// ExtractPipedToken returns the verified piped key inside a given context. -func ExtractPipedToken(ctx context.Context) (projectID, pipedID, pipedKey string, err error) { - v, ok := ctx.Value(pipedTokenKey).(pipedTokenContextValue) - if !ok { - err = errUnauthenticated - return - } - projectID = v.ProjectID - pipedID = v.PipedID - pipedKey = v.PipedKey - return -} - // ExtractClaims returns the claims inside a given context. func ExtractClaims(ctx context.Context) (jwt.Claims, error) { claims, ok := ctx.Value(claimsKey).(jwt.Claims) diff --git a/pkg/rpc/rpcauth/interceptor_test.go b/pkg/rpc/rpcauth/interceptor_test.go index 614514335f..a8ee47f6ca 100644 --- a/pkg/rpc/rpcauth/interceptor_test.go +++ b/pkg/rpc/rpcauth/interceptor_test.go @@ -21,9 +21,12 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/metadata" + + "github.com/pipe-cd/pipe/pkg/model" ) type fakeServerStream struct { @@ -164,3 +167,73 @@ func TestPipedTokenStreamServerInterceptor(t *testing.T) { }) } } + +type testAPIKeyVerifier struct { + keyString string + key *model.APIKey +} + +func (v testAPIKeyVerifier) Verify(_ context.Context, key string) (*model.APIKey, error) { + if key != v.keyString { + return nil, fmt.Errorf("invalid API key, want: %s, got: %s", v.keyString, key) + } + return v.key, nil +} + +func TestAPIKeyUnaryServerInterceptor(t *testing.T) { + verifier := testAPIKeyVerifier{ + keyString: "test-api-key", + key: &model.APIKey{ + Id: "test-api-key", + }, + } + in := APIKeyUnaryServerInterceptor(verifier, zap.NewNop()) + testcases := []struct { + name string + ctx context.Context + expectedKey *model.APIKey + errString string + }{ + { + name: "missing credentials", + ctx: context.TODO(), + errString: "rpc error: code = Unauthenticated desc = missing credentials", + }, + { + name: "wrong credentials type", + ctx: metadata.NewIncomingContext(context.Background(), metadata.MD{ + "authorization": []string{"PIPED-TOKEN test-project-id,test-piped-id,test-piped-key"}, + }), + errString: "rpc error: code = Unauthenticated desc = Unauthenticated", + }, + { + name: "ok", + ctx: metadata.NewIncomingContext(context.Background(), metadata.MD{ + "authorization": []string{"API-KEY test-api-key"}, + }), + expectedKey: &model.APIKey{ + Id: "test-api-key", + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + _, err := in(tc.ctx, nil, nil, func(ctx context.Context, req interface{}) (interface{}, error) { + apiKey, err := ExtractAPIKey(ctx) + if err != nil { + return nil, err + } + if apiKey.Id != tc.expectedKey.Id { + return nil, errors.New("invalid api key") + } + return nil, nil + }) + if tc.errString != "" { + require.NotNil(t, err) + assert.Equal(t, tc.errString, err.Error()) + } else { + assert.Nil(t, err) + } + }) + } +} diff --git a/pkg/rpc/server.go b/pkg/rpc/server.go index 548bf3b7d2..d075f13b17 100644 --- a/pkg/rpc/server.go +++ b/pkg/rpc/server.go @@ -48,6 +48,7 @@ type Server struct { pipedKeyAuthUnaryInterceptor grpc.UnaryServerInterceptor pipedKeyAuthStreamInterceptor grpc.StreamServerInterceptor + apiKeyAuthUnaryInterceptor grpc.UnaryServerInterceptor jwtAuthUnaryInterceptor grpc.UnaryServerInterceptor requestValidationUnaryInterceptor grpc.UnaryServerInterceptor logUnaryInterceptor grpc.UnaryServerInterceptor @@ -77,6 +78,13 @@ func WithPipedTokenAuthStreamInterceptor(verifier rpcauth.PipedTokenVerifier, lo } } +// WithAPIKeyAuthUnaryInterceptor sets an interceptor for validating API key. +func WithAPIKeyAuthUnaryInterceptor(verifier rpcauth.APIKeyVerifier, logger *zap.Logger) Option { + return func(s *Server) { + s.apiKeyAuthUnaryInterceptor = rpcauth.APIKeyUnaryServerInterceptor(verifier, logger) + } +} + // WithJWTAuthUnaryInterceptor sets an interceprot for checking JWT token. func WithJWTAuthUnaryInterceptor(verifier jwt.Verifier, authorizer rpcauth.RBACAuthorizer, logger *zap.Logger) Option { return func(s *Server) { @@ -191,6 +199,9 @@ func (s *Server) init() error { if s.pipedKeyAuthUnaryInterceptor != nil { unaryInterceptors = append(unaryInterceptors, s.pipedKeyAuthUnaryInterceptor) } + if s.apiKeyAuthUnaryInterceptor != nil { + unaryInterceptors = append(unaryInterceptors, s.apiKeyAuthUnaryInterceptor) + } if s.jwtAuthUnaryInterceptor != nil { unaryInterceptors = append(unaryInterceptors, s.jwtAuthUnaryInterceptor) }