From 4ab704b7de7493325b23e595c2dd0c99e11139c7 Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Fri, 9 Apr 2021 11:38:53 -0400 Subject: [PATCH] ldap: add initial stub upstream LDAP connection package Signed-off-by: Andrew Keesler --- go.mod | 1 + go.sum | 7 ++ internal/mocks/mockldapconn/generate.go | 6 ++ internal/mocks/mockldapconn/mockldapconn.go | 80 +++++++++++++++ internal/upstreamldap/upstreamldap.go | 70 +++++++++++++ internal/upstreamldap/upstreamldap_test.go | 104 ++++++++++++++++++++ 6 files changed, 268 insertions(+) create mode 100644 internal/mocks/mockldapconn/generate.go create mode 100644 internal/mocks/mockldapconn/mockldapconn.go create mode 100644 internal/upstreamldap/upstreamldap.go create mode 100644 internal/upstreamldap/upstreamldap_test.go diff --git a/go.mod b/go.mod index 9541d8da..550cb6dd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/coreos/go-oidc/v3 v3.0.0 github.com/davecgh/go-spew v1.1.1 + github.com/go-ldap/ldap/v3 v3.3.0 github.com/go-logr/logr v0.4.0 github.com/go-logr/stdr v0.4.0 github.com/go-openapi/spec v0.20.3 diff --git a/go.sum b/go.sum index 919c1ecc..50e700d8 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/Azure/go-autorest/logger v0.2.0 h1:e4RVHVZKC5p6UANLJHkM4OfR1UKZPj8Wt8 github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -201,12 +203,16 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= +github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-bindata/go-bindata v3.1.1+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-ldap/ldap/v3 v3.3.0 h1:lwx+SJpgOHd8tG6SumBQZXCmNX51zM8B1cfxJ5gv4tQ= +github.com/go-ldap/ldap/v3 v3.3.0/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= @@ -1094,6 +1100,7 @@ golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200320181102-891825fb96df/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= diff --git a/internal/mocks/mockldapconn/generate.go b/internal/mocks/mockldapconn/generate.go new file mode 100644 index 00000000..e9bf5943 --- /dev/null +++ b/internal/mocks/mockldapconn/generate.go @@ -0,0 +1,6 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package mockldapconn + +//go:generate go run -v github.com/golang/mock/mockgen -destination=mockldapconn.go -package=mockldapconn -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/upstreamldap Conn diff --git a/internal/mocks/mockldapconn/mockldapconn.go b/internal/mocks/mockldapconn/mockldapconn.go new file mode 100644 index 00000000..a96cf79c --- /dev/null +++ b/internal/mocks/mockldapconn/mockldapconn.go @@ -0,0 +1,80 @@ +// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: go.pinniped.dev/internal/upstreamldap (interfaces: Conn) + +// Package mockldapconn is a generated GoMock package. +package mockldapconn + +import ( + reflect "reflect" + + ldap "github.com/go-ldap/ldap/v3" + gomock "github.com/golang/mock/gomock" +) + +// MockConn is a mock of Conn interface. +type MockConn struct { + ctrl *gomock.Controller + recorder *MockConnMockRecorder +} + +// MockConnMockRecorder is the mock recorder for MockConn. +type MockConnMockRecorder struct { + mock *MockConn +} + +// NewMockConn creates a new mock instance. +func NewMockConn(ctrl *gomock.Controller) *MockConn { + mock := &MockConn{ctrl: ctrl} + mock.recorder = &MockConnMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockConn) EXPECT() *MockConnMockRecorder { + return m.recorder +} + +// Bind mocks base method. +func (m *MockConn) Bind(arg0, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Bind", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Bind indicates an expected call of Bind. +func (mr *MockConnMockRecorder) Bind(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Bind", reflect.TypeOf((*MockConn)(nil).Bind), arg0, arg1) +} + +// Close mocks base method. +func (m *MockConn) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockConnMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockConn)(nil).Close)) +} + +// Search mocks base method. +func (m *MockConn) Search(arg0 *ldap.SearchRequest) (*ldap.SearchResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", arg0) + ret0, _ := ret[0].(*ldap.SearchResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search. +func (mr *MockConnMockRecorder) Search(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockConn)(nil).Search), arg0) +} diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go new file mode 100644 index 00000000..bc8a7eee --- /dev/null +++ b/internal/upstreamldap/upstreamldap.go @@ -0,0 +1,70 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package upstreamldap implements an abstraction of upstream LDAP IDP interactions. +package upstreamldap + +import ( + "context" + + ldap "github.com/go-ldap/ldap/v3" + "k8s.io/apiserver/pkg/authentication/authenticator" +) + +// Conn abstracts the upstream LDAP communication protocol (mostly for testing). +type Conn interface { + // Bind abstracts ldap.Conn.Bind(). + Bind(username, password string) error + // Search abstracts ldap.Conn.Search(). + Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) + // Close abstracts ldap.Conn.Close(). + Close() +} + +// UserSearch contains information about how to search for users in the upstream LDAP IDP. +type UserSearch struct { + // Base is the base DN to use for the user search in the upstream LDAP IDP. + Base string + // Filter is the filter to use for the user search in the upstream LDAP IDP. + Filter string + // UsernameAttribute is the attribute in the LDAP entry from which the username should be + // retrieved. + UsernameAttribute string + // UIDAttribute is the attribute in the LDAP entry from which the user's unique ID should be + // retrieved. + UIDAttribute string +} + +// Provider contains can interact with an upstream LDAP IDP. +type Provider struct { + // Name is the unique name of this upstream LDAP IDP. + Name string + // URL is the URL of this upstream LDAP IDP. + URL string + + // Dial is a func that, given a URL, will return an LDAPConn to use for communicating with an + // upstream LDAP IDP. + Dial func(ctx context.Context, url string) (Conn, error) + + // BindUsername is the username to use when performing a bind with the upstream LDAP IDP. + BindUsername string + // BindPassword is the password to use when performing a bind with the upstream LDAP IDP. + BindPassword string + + // UserSearch contains information about how to search for users in the upstream LDAP IDP. + UserSearch *UserSearch +} + +func (p *Provider) GetName() string { + return p.Name +} + +func (p *Provider) GetURL() string { + return p.URL +} + +func (p *Provider) AuthenticateUser(ctx context.Context, username, password string) (*authenticator.Response, bool, error) { + // TODO: test context timeout? + // TODO: test dial context timeout? + return nil, false, nil +} diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go new file mode 100644 index 00000000..7f739144 --- /dev/null +++ b/internal/upstreamldap/upstreamldap_test.go @@ -0,0 +1,104 @@ +// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package upstreamldap + +import ( + "context" + "testing" + + ldap "github.com/go-ldap/ldap/v3" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/user" + + "go.pinniped.dev/internal/mocks/mockldapconn" +) + +var ( + upstreamUsername = "some-upstream-username" + upstreamPassword = "some-upstream-password" + upstreamGroups = []string{"some-upstream-group-0", "some-upstream-group-1"} + upstreamUID = "some-upstream-uid" +) + +func TestAuthenticateUser(t *testing.T) { + // Please the linter... + _ = upstreamGroups + _ = upstreamUID + t.Skip("TODO: make me pass!") + + tests := []struct { + name string + provider *Provider + wantError string + wantUnauthenticated bool + wantAuthResponse *authenticator.Response + }{ + { + name: "happy path", + provider: &Provider{ + URL: "ldaps://some-ldap-url:1234", + BindUsername: upstreamUsername, + BindPassword: upstreamPassword, + UserSearch: &UserSearch{ + Base: "some-upstream-base-dn", + Filter: "some-filter", + UsernameAttribute: "some-upstream-username-attribute", + UIDAttribute: "some-upstream-uid-attribute", + }, + }, + wantAuthResponse: &authenticator.Response{ + User: &user.DefaultInfo{ + Name: upstreamUsername, + Groups: upstreamGroups, + UID: upstreamUID, + }, + }, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + conn := mockldapconn.NewMockConn(ctrl) + conn.EXPECT().Bind(test.provider.BindUsername, test.provider.BindPassword).Times(1) + conn.EXPECT().Search(&ldap.SearchRequest{ + BaseDN: test.provider.UserSearch.Base, + Scope: 99, // TODO: what should this be? + DerefAliases: 99, // TODO: what should this be? + SizeLimit: 99, + TimeLimit: 99, // TODO: what should this be? + TypesOnly: true, // TODO: what should this be? + Filter: test.provider.UserSearch.Filter, + Attributes: []string{}, // TODO: what should this be? + Controls: []ldap.Control{}, // TODO: what should this be? + }).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: "", // TODO: what should this be? + Attributes: []*ldap.EntryAttribute{}, // TODO: what should this be? + }, + }, + Referrals: []string{}, // TODO: what should this be? + Controls: []ldap.Control{}, // TODO: what should this be? + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + + test.provider.Dial = func(ctx context.Context, url string) (Conn, error) { + require.Equal(t, test.provider.URL, url) + return conn, nil + } + + authResponse, authenticated, err := test.provider.AuthenticateUser(context.Background(), upstreamUsername, upstreamPassword) + if test.wantError != "" { + require.EqualError(t, err, test.wantError) + return + } + require.Equal(t, !test.wantUnauthenticated, authenticated) + require.Equal(t, test.wantAuthResponse, authResponse) + }) + } +}