From 52e4c7eda7e0784e02328310afbe69d1d4771347 Mon Sep 17 00:00:00 2001 From: Stuart Carnie Date: Fri, 30 Oct 2020 10:06:34 -0700 Subject: [PATCH] feat: Implementation of AuthorizerV1 The `AuthorizerV1` defines the behavior for authorizing an InfluxDB 1.x API using `CredentialsV1`. These credentials are extracted from an API, such as the Authorization header of a HTTP request. --- credentials.go | 37 +++ mock/authorizer_v1.go | 15 + v1/authorization/authorizer.go | 124 +++++++ v1/authorization/authorizer_test.go | 339 ++++++++++++++++++++ v1/authorization/mocks/auth_token_finder.go | 50 +++ v1/authorization/mocks/password_comparer.go | 49 +++ v1/authorization/mocks/user_finder.go | 50 +++ 7 files changed, 664 insertions(+) create mode 100644 credentials.go create mode 100644 mock/authorizer_v1.go create mode 100644 v1/authorization/authorizer.go create mode 100644 v1/authorization/authorizer_test.go create mode 100644 v1/authorization/mocks/auth_token_finder.go create mode 100644 v1/authorization/mocks/password_comparer.go create mode 100644 v1/authorization/mocks/user_finder.go diff --git a/credentials.go b/credentials.go new file mode 100644 index 00000000000..0e7eea08746 --- /dev/null +++ b/credentials.go @@ -0,0 +1,37 @@ +package influxdb + +import "context" + +var ( + // ErrCredentialsUnauthorized is the error returned when CredentialsV1 cannot be + // authorized. + ErrCredentialsUnauthorized = &Error{ + Code: EUnauthorized, + Msg: "Unauthorized", + } +) + +// SchemeV1 is an enumeration of supported authorization types +type SchemeV1 string + +const ( + // SchemeV1Basic indicates the credentials came from an Authorization header using the BASIC scheme + SchemeV1Basic SchemeV1 = "basic" + + // SchemeToken indicates the credentials came from an Authorization header using the Token scheme + SchemeV1Token SchemeV1 = "token" + + // SchemeURL indicates the credentials came from the u and p query parameters + SchemeV1URL SchemeV1 = "url" +) + +// CredentialsV1 encapsulates the required credentials to authorize a v1 HTTP request. +type CredentialsV1 struct { + Scheme SchemeV1 + Username string + Token string +} + +type AuthorizerV1 interface { + Authorize(ctx context.Context, v1 CredentialsV1) (*Authorization, error) +} diff --git a/mock/authorizer_v1.go b/mock/authorizer_v1.go new file mode 100644 index 00000000000..62b15226777 --- /dev/null +++ b/mock/authorizer_v1.go @@ -0,0 +1,15 @@ +package mock + +import ( + "context" + + "github.com/influxdata/influxdb/v2" +) + +type AuthorizerV1 struct { + AuthorizeFn func(ctx context.Context, c influxdb.CredentialsV1) (*influxdb.Authorization, error) +} + +func (a *AuthorizerV1) Authorize(ctx context.Context, c influxdb.CredentialsV1) (*influxdb.Authorization, error) { + return a.AuthorizeFn(ctx, c) +} diff --git a/v1/authorization/authorizer.go b/v1/authorization/authorizer.go new file mode 100644 index 00000000000..5d778d0a566 --- /dev/null +++ b/v1/authorization/authorizer.go @@ -0,0 +1,124 @@ +package authorization + +import ( + "context" + "errors" + + "github.com/influxdata/influxdb/v2" +) + +var ( + ErrUnsupportedScheme = &influxdb.Error{ + Code: influxdb.EInternal, + Msg: "unsupported authorization scheme", + } +) + +type UserFinder interface { + // Returns a single user by ID. + FindUserByID(ctx context.Context, id influxdb.ID) (*influxdb.User, error) +} + +type PasswordComparer interface { + ComparePassword(ctx context.Context, authID influxdb.ID, password string) error +} + +type AuthTokenFinder interface { + FindAuthorizationByToken(ctx context.Context, token string) (*influxdb.Authorization, error) +} + +// A type that is used to verify credentials. +type Authorizer struct { + AuthV1 AuthTokenFinder // A service to find V1 tokens + AuthV2 AuthTokenFinder // A service to find V2 tokens + Comparer PasswordComparer // A service to compare passwords for V1 tokens + User UserFinder // A service to find users +} + +// Authorize returns an influxdb.Authorization if c can be verified; otherwise, an error. +// influxdb.ErrCredentialsUnauthorized will be returned if the credentials are invalid. +func (v *Authorizer) Authorize(ctx context.Context, c influxdb.CredentialsV1) (auth *influxdb.Authorization, err error) { + // the defer function provides the following guarantees: + // * the authorization token status is active and + // * the user status is active + defer func() { + if err != nil { + return + } + + if auth == nil { + return + } + + if auth.Status != influxdb.Active { + auth, err = nil, influxdb.ErrCredentialsUnauthorized + return + } + + // check the user is still active + if user, userErr := v.User.FindUserByID(ctx, auth.UserID); err != nil { + auth, err = nil, v.normalizeError(userErr) + return + } else if user == nil || user.Status != influxdb.Active { + auth, err = nil, influxdb.ErrCredentialsUnauthorized + return + } + }() + + switch c.Scheme { + case influxdb.SchemeV1Basic, influxdb.SchemeV1URL: + auth, err = v.tryV1Authorization(ctx, c) + if errors.Is(err, ErrAuthNotFound) { + return v.tryV2Authorization(ctx, c) + } + + if err != nil { + return nil, v.normalizeError(err) + } + return + + case influxdb.SchemeV1Token: + return v.tryV2Authorization(ctx, c) + + default: + // this represents a programmer error + return nil, ErrUnsupportedScheme + } +} + +func (v *Authorizer) tryV1Authorization(ctx context.Context, c influxdb.CredentialsV1) (auth *influxdb.Authorization, err error) { + auth, err = v.AuthV1.FindAuthorizationByToken(ctx, c.Username) + if err != nil { + return nil, err + } + + if err := v.Comparer.ComparePassword(ctx, auth.ID, c.Token); err != nil { + return nil, err + } + + return auth, nil +} + +func (v *Authorizer) tryV2Authorization(ctx context.Context, c influxdb.CredentialsV1) (auth *influxdb.Authorization, err error) { + auth, err = v.AuthV2.FindAuthorizationByToken(ctx, c.Token) + if err != nil { + return nil, v.normalizeError(err) + } + return auth, nil +} + +func (v *Authorizer) normalizeError(err error) error { + if err == nil { + return nil + } + + var erri *influxdb.Error + if errors.As(err, &erri) { + switch erri.Code { + case influxdb.ENotFound, influxdb.EForbidden: + return influxdb.ErrCredentialsUnauthorized + } + } + + return err +} diff --git a/v1/authorization/authorizer_test.go b/v1/authorization/authorizer_test.go new file mode 100644 index 00000000000..61f19eb83ba --- /dev/null +++ b/v1/authorization/authorizer_test.go @@ -0,0 +1,339 @@ +package authorization + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/influxdata/influxdb/v2" + itesting "github.com/influxdata/influxdb/v2/testing" + "github.com/influxdata/influxdb/v2/v1/authorization/mocks" + "github.com/stretchr/testify/assert" +) + +func TestAuthorizer_Authorize(t *testing.T) { + var ( + username = "foo" + token = "bar" + authID = itesting.MustIDBase16("0000000000001234") + userID = itesting.MustIDBase16("000000000000fefe") + expAuthErr = influxdb.ErrCredentialsUnauthorized.Error() + + auth = &influxdb.Authorization{ + ID: authID, + UserID: userID, + Token: username, + Status: influxdb.Active, + } + + user = &influxdb.User{ + ID: userID, + Status: influxdb.Active, + } + ) + + t.Run("invalid scheme returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + authz := Authorizer{} + + cred := influxdb.CredentialsV1{ + Scheme: "foo", + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, ErrUnsupportedScheme.Error()) + }) + + tests := func(t *testing.T, scheme influxdb.SchemeV1) { + t.Run("invalid v1 and v2 token returns expected error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(nil, ErrAuthNotFound) + + v2 := mocks.NewMockAuthTokenFinder(ctrl) + v2.EXPECT(). + FindAuthorizationByToken(ctx, token). + Return(nil, ErrAuthNotFound) + + authz := Authorizer{ + AuthV1: v1, + AuthV2: v2, + } + + cred := influxdb.CredentialsV1{ + Scheme: scheme, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, expAuthErr) + }) + + t.Run("valid v1 token and invalid password returns expected error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(auth, nil) + + pw := mocks.NewMockPasswordComparer(ctrl) + pw.EXPECT(). + ComparePassword(ctx, authID, token). + Return(EIncorrectPassword) + + authz := Authorizer{ + AuthV1: v1, + Comparer: pw, + } + + cred := influxdb.CredentialsV1{ + Scheme: scheme, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, expAuthErr) + }) + + t.Run("valid v1 token and password returns authorization", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(auth, nil) + + pw := mocks.NewMockPasswordComparer(ctrl) + pw.EXPECT(). + ComparePassword(ctx, authID, token). + Return(nil) + + uf := mocks.NewMockUserFinder(ctrl) + uf.EXPECT(). + FindUserByID(ctx, userID). + Return(user, nil) + + authz := Authorizer{ + AuthV1: v1, + Comparer: pw, + User: uf, + } + + cred := influxdb.CredentialsV1{ + Scheme: scheme, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.NoError(t, gotErr) + assert.Equal(t, auth, gotAuth) + }) + + t.Run("invalid v1 token and valid v2 token returns authorization", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(nil, ErrAuthNotFound) + + v2 := mocks.NewMockAuthTokenFinder(ctrl) + v2.EXPECT(). + FindAuthorizationByToken(ctx, token). + Return(auth, nil) + + uf := mocks.NewMockUserFinder(ctrl) + uf.EXPECT(). + FindUserByID(ctx, userID). + Return(user, nil) + + authz := Authorizer{ + AuthV1: v1, + AuthV2: v2, + User: uf, + } + + cred := influxdb.CredentialsV1{ + Scheme: scheme, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.NoError(t, gotErr) + assert.Equal(t, auth, gotAuth) + }) + } + + t.Run("using Basic scheme", func(t *testing.T) { + tests(t, influxdb.SchemeV1Basic) + }) + + t.Run("using URL scheme", func(t *testing.T) { + tests(t, influxdb.SchemeV1URL) + }) + + t.Run("using Token scheme", func(t *testing.T) { + t.Run("invalid v2 token returns expected error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v2 := mocks.NewMockAuthTokenFinder(ctrl) + v2.EXPECT(). + FindAuthorizationByToken(ctx, token). + Return(nil, ErrAuthNotFound) + + authz := Authorizer{ + AuthV2: v2, + } + + cred := influxdb.CredentialsV1{ + Scheme: influxdb.SchemeV1Token, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, expAuthErr) + }) + + t.Run("valid v2 token returns authorization", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v2 := mocks.NewMockAuthTokenFinder(ctrl) + v2.EXPECT(). + FindAuthorizationByToken(ctx, token). + Return(auth, nil) + + uf := mocks.NewMockUserFinder(ctrl) + uf.EXPECT(). + FindUserByID(ctx, userID). + Return(user, nil) + + authz := Authorizer{ + AuthV2: v2, + User: uf, + } + + cred := influxdb.CredentialsV1{ + Scheme: influxdb.SchemeV1Token, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.NoError(t, gotErr) + assert.Equal(t, auth, gotAuth) + }) + }) + + // test inactive user and inactive token + + t.Run("inactive user returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(auth, nil) + + pw := mocks.NewMockPasswordComparer(ctrl) + pw.EXPECT(). + ComparePassword(ctx, authID, token). + Return(nil) + + user := *user + user.Status = influxdb.Inactive + + uf := mocks.NewMockUserFinder(ctrl) + uf.EXPECT(). + FindUserByID(ctx, userID). + Return(&user, nil) + + authz := Authorizer{ + AuthV1: v1, + Comparer: pw, + User: uf, + } + + cred := influxdb.CredentialsV1{ + Scheme: influxdb.SchemeV1Basic, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, expAuthErr) + }) + + t.Run("inactive token returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + ctx := context.Background() + + auth := *auth + auth.Status = influxdb.Inactive + + v1 := mocks.NewMockAuthTokenFinder(ctrl) + v1.EXPECT(). + FindAuthorizationByToken(ctx, username). + Return(&auth, nil) + + pw := mocks.NewMockPasswordComparer(ctrl) + pw.EXPECT(). + ComparePassword(ctx, authID, token). + Return(nil) + + authz := Authorizer{ + AuthV1: v1, + Comparer: pw, + } + + cred := influxdb.CredentialsV1{ + Scheme: influxdb.SchemeV1Basic, + Username: username, + Token: token, + } + + gotAuth, gotErr := authz.Authorize(ctx, cred) + assert.Nil(t, gotAuth) + assert.EqualError(t, gotErr, expAuthErr) + }) +} diff --git a/v1/authorization/mocks/auth_token_finder.go b/v1/authorization/mocks/auth_token_finder.go new file mode 100644 index 00000000000..4492d227777 --- /dev/null +++ b/v1/authorization/mocks/auth_token_finder.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/influxdata/influxdb/v2/v1/authorization (interfaces: AuthTokenFinder) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + influxdb "github.com/influxdata/influxdb/v2" + reflect "reflect" +) + +// MockAuthTokenFinder is a mock of AuthTokenFinder interface +type MockAuthTokenFinder struct { + ctrl *gomock.Controller + recorder *MockAuthTokenFinderMockRecorder +} + +// MockAuthTokenFinderMockRecorder is the mock recorder for MockAuthTokenFinder +type MockAuthTokenFinderMockRecorder struct { + mock *MockAuthTokenFinder +} + +// NewMockAuthTokenFinder creates a new mock instance +func NewMockAuthTokenFinder(ctrl *gomock.Controller) *MockAuthTokenFinder { + mock := &MockAuthTokenFinder{ctrl: ctrl} + mock.recorder = &MockAuthTokenFinderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockAuthTokenFinder) EXPECT() *MockAuthTokenFinderMockRecorder { + return m.recorder +} + +// FindAuthorizationByToken mocks base method +func (m *MockAuthTokenFinder) FindAuthorizationByToken(arg0 context.Context, arg1 string) (*influxdb.Authorization, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindAuthorizationByToken", arg0, arg1) + ret0, _ := ret[0].(*influxdb.Authorization) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindAuthorizationByToken indicates an expected call of FindAuthorizationByToken +func (mr *MockAuthTokenFinderMockRecorder) FindAuthorizationByToken(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAuthorizationByToken", reflect.TypeOf((*MockAuthTokenFinder)(nil).FindAuthorizationByToken), arg0, arg1) +} diff --git a/v1/authorization/mocks/password_comparer.go b/v1/authorization/mocks/password_comparer.go new file mode 100644 index 00000000000..87d014e4d86 --- /dev/null +++ b/v1/authorization/mocks/password_comparer.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/influxdata/influxdb/v2/v1/authorization (interfaces: PasswordComparer) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + influxdb "github.com/influxdata/influxdb/v2" + reflect "reflect" +) + +// MockPasswordComparer is a mock of PasswordComparer interface +type MockPasswordComparer struct { + ctrl *gomock.Controller + recorder *MockPasswordComparerMockRecorder +} + +// MockPasswordComparerMockRecorder is the mock recorder for MockPasswordComparer +type MockPasswordComparerMockRecorder struct { + mock *MockPasswordComparer +} + +// NewMockPasswordComparer creates a new mock instance +func NewMockPasswordComparer(ctrl *gomock.Controller) *MockPasswordComparer { + mock := &MockPasswordComparer{ctrl: ctrl} + mock.recorder = &MockPasswordComparerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockPasswordComparer) EXPECT() *MockPasswordComparerMockRecorder { + return m.recorder +} + +// ComparePassword mocks base method +func (m *MockPasswordComparer) ComparePassword(arg0 context.Context, arg1 influxdb.ID, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ComparePassword", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// ComparePassword indicates an expected call of ComparePassword +func (mr *MockPasswordComparerMockRecorder) ComparePassword(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ComparePassword", reflect.TypeOf((*MockPasswordComparer)(nil).ComparePassword), arg0, arg1, arg2) +} diff --git a/v1/authorization/mocks/user_finder.go b/v1/authorization/mocks/user_finder.go new file mode 100644 index 00000000000..a1aec48f6f1 --- /dev/null +++ b/v1/authorization/mocks/user_finder.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/influxdata/influxdb/v2/v1/authorization (interfaces: UserFinder) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + gomock "github.com/golang/mock/gomock" + influxdb "github.com/influxdata/influxdb/v2" + reflect "reflect" +) + +// MockUserFinder is a mock of UserFinder interface +type MockUserFinder struct { + ctrl *gomock.Controller + recorder *MockUserFinderMockRecorder +} + +// MockUserFinderMockRecorder is the mock recorder for MockUserFinder +type MockUserFinderMockRecorder struct { + mock *MockUserFinder +} + +// NewMockUserFinder creates a new mock instance +func NewMockUserFinder(ctrl *gomock.Controller) *MockUserFinder { + mock := &MockUserFinder{ctrl: ctrl} + mock.recorder = &MockUserFinderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockUserFinder) EXPECT() *MockUserFinderMockRecorder { + return m.recorder +} + +// FindUserByID mocks base method +func (m *MockUserFinder) FindUserByID(arg0 context.Context, arg1 influxdb.ID) (*influxdb.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindUserByID", arg0, arg1) + ret0, _ := ret[0].(*influxdb.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindUserByID indicates an expected call of FindUserByID +func (mr *MockUserFinderMockRecorder) FindUserByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindUserByID", reflect.TypeOf((*MockUserFinder)(nil).FindUserByID), arg0, arg1) +}