Return unauthenticated instead of error for bad username or password

- Bad usernames and passwords aren't really errors, since they are
  based on end-user input.
- Other kinds of authentication failures are caused by bad configuration
  so still treat those as errors.
- Empty usernames and passwords are already prevented by our endpoint
  handler, but just to be safe make sure they cause errors inside the
  authenticator too.
This commit is contained in:
Ryan Richard 2021-04-13 16:22:13 -07:00
parent fec3d92f26
commit 51263a0f07
5 changed files with 123 additions and 51 deletions

View File

@ -95,10 +95,11 @@ func handleAuthRequestForLDAPUpstream(
authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password)
if err != nil {
plog.WarningErr("unexpected error during upstream authentication", err, "upstreamName", ldapUpstream.GetName())
plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", ldapUpstream.GetName())
return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication")
}
if !authenticated {
plog.Debug("failed upstream LDAP authentication", "upstreamName", ldapUpstream.GetName())
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."))
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)

View File

@ -15,12 +15,15 @@ import (
"github.com/go-ldap/ldap/v3"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/plog"
)
const (
ldapsScheme = "ldaps"
distinguishedNameAttributeName = "dn"
userSearchFilterInterpolationLocationMarker = "{}"
invalidCredentialsErrorPrefix = `LDAP Result Code 49 "Invalid Credentials":`
)
// Conn abstracts the upstream LDAP communication protocol (mostly for testing).
@ -185,6 +188,11 @@ func (p *Provider) AuthenticateUser(ctx context.Context, username, password stri
return nil, false, fmt.Errorf(`must specify UserSearch Filter when UserSearch UsernameAttribute is "dn"`)
}
if len(username) == 0 {
// Empty passwords are already handled by go-ldap.
return nil, false, nil
}
conn, err := p.dial(ctx)
if err != nil {
return nil, false, fmt.Errorf(`error dialing host "%s": %w`, p.Host, err)
@ -200,6 +208,10 @@ func (p *Provider) AuthenticateUser(ctx context.Context, username, password stri
if err != nil {
return nil, false, err
}
if len(mappedUsername) == 0 || len(mappedUID) == 0 {
// Couldn't find the username or couldn't bind using the password.
return nil, false, nil
}
response := &authenticator.Response{
User: &user.DefaultInfo{
@ -216,7 +228,12 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, password string
if err != nil {
return "", "", fmt.Errorf(`error searching for user "%s": %w`, username, err)
}
if len(searchResult.Entries) != 1 {
if len(searchResult.Entries) == 0 {
plog.Debug("error finding user: user not found (if this username is valid, please check the user search configuration)",
"upstreamName", p.GetName(), "username", username)
return "", "", nil
}
if len(searchResult.Entries) > 1 {
return "", "", fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`,
username, len(searchResult.Entries),
)
@ -236,9 +253,14 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, password string
return "", "", err
}
// Take care that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername!
// Caution: Note that any other LDAP commands after this bind will be run as this user instead of as the configured BindUsername!
err = conn.Bind(userEntry.DN, password)
if err != nil {
plog.DebugErr("error binding for user (if this is not the expected dn for this username, please check the user search configuration)",
err, "upstreamName", p.GetName(), "username", username, "dn", userEntry.DN)
if strings.HasPrefix(err.Error(), invalidCredentialsErrorPrefix) {
return "", "", nil
}
return "", "", fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err)
}

View File

@ -80,15 +80,16 @@ func TestAuthenticateUser(t *testing.T) {
}
tests := []struct {
name string
username string
password string
provider *Provider
setupMocks func(conn *mockldapconn.MockConn)
dialError error
wantError string
wantToSkipDial bool
wantAuthResponse *authenticator.Response
name string
username string
password string
provider *Provider
setupMocks func(conn *mockldapconn.MockConn)
dialError error
wantError string
wantToSkipDial bool
wantAuthResponse *authenticator.Response
wantUnauthenticated bool
}{
{
name: "happy path",
@ -282,6 +283,8 @@ func TestAuthenticateUser(t *testing.T) {
},
{
name: "when dial fails",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(nil),
dialError: errors.New("some dial error"),
wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost),
@ -299,6 +302,8 @@ func TestAuthenticateUser(t *testing.T) {
},
{
name: "when binding as the bind user returns an error",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(nil),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1)
@ -330,7 +335,7 @@ func TestAuthenticateUser(t *testing.T) {
}, nil).Times(1)
conn.EXPECT().Close().Times(1)
},
wantError: fmt.Sprintf(`searching for user "%s" resulted in 0 search results, but expected 1 result`, testUpstreamUsername),
wantUnauthenticated: true,
},
{
name: "when searching for the user returns multiple results",
@ -524,6 +529,37 @@ func TestAuthenticateUser(t *testing.T) {
},
wantError: fmt.Sprintf(`error binding for user "%s" using provided password against DN "%s": some bind error`, testUpstreamUsername, testSearchResultDNValue),
},
{
name: "when binding as the found user returns a specific invalid credentials error",
username: testUpstreamUsername,
password: testUpstreamPassword,
provider: provider(nil),
setupMocks: func(conn *mockldapconn.MockConn) {
conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1)
conn.EXPECT().Search(expectedSearch(nil)).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: testSearchResultDNValue,
Attributes: []*ldap.EntryAttribute{
ldap.NewEntryAttribute(testUserSearchUsernameAttribute, []string{testSearchResultUsernameAttributeValue}),
ldap.NewEntryAttribute(testUserSearchUIDAttribute, []string{testSearchResultUIDAttributeValue}),
},
},
},
}, nil).Times(1)
conn.EXPECT().Bind(testSearchResultDNValue, testUpstreamPassword).Return(errors.New(`LDAP Result Code 49 "Invalid Credentials": some bind error`)).Times(1)
conn.EXPECT().Close().Times(1)
},
wantUnauthenticated: true,
},
{
name: "when no username is specified",
username: "",
password: testUpstreamPassword,
provider: provider(nil),
wantToSkipDial: true,
wantUnauthenticated: true,
},
}
for _, test := range tests {
@ -551,11 +587,16 @@ func TestAuthenticateUser(t *testing.T) {
require.Equal(t, !tt.wantToSkipDial, dialWasAttempted)
if tt.wantError != "" {
switch {
case tt.wantError != "":
require.EqualError(t, err, tt.wantError)
require.False(t, authenticated)
require.Nil(t, authResponse)
} else {
case tt.wantUnauthenticated:
require.NoError(t, err)
require.False(t, authenticated)
require.Nil(t, authResponse)
default:
require.NoError(t, err)
require.True(t, authenticated)
require.Equal(t, tt.wantAuthResponse, authResponse)

View File

@ -14,7 +14,7 @@ stringData:
#@yaml/text-templated-strings
ldap.ldif: |
# ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! ***
# Here's a good explaination of LDIF:
# Here's a good explanation of LDIF:
# https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system
# pinniped.dev (organization, root)

View File

@ -24,6 +24,8 @@ import (
"go.pinniped.dev/internal/upstreamldap"
)
// Unlike most other integration tests, you can run this test with no special setup, as long as you have Docker.
// It does not depend on Kubernetes.
func TestLDAPSearch(t *testing.T) {
ctx, cancelFunc := context.WithCancel(context.Background())
t.Cleanup(func() {
@ -57,12 +59,13 @@ func TestLDAPSearch(t *testing.T) {
wallyPassword := "password456" // from the LDIF file below
tests := []struct {
name string
username string
password string
provider *upstreamldap.Provider
wantError string
wantAuthResponse *authenticator.Response
name string
username string
password string
provider *upstreamldap.Provider
wantError string
wantAuthResponse *authenticator.Response
wantUnauthenticated bool
}{
{
name: "happy path",
@ -193,18 +196,18 @@ func TestLDAPSearch(t *testing.T) {
wantError: `error binding as "cn=admin,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": `,
},
{
name: "when the end user password is wrong",
username: "pinny",
password: "wrong-pinny-password",
provider: provider(nil),
wantError: `error binding for user "pinny" using provided password against DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 49 "Invalid Credentials": `,
name: "when the end user password is wrong",
username: "pinny",
password: "wrong-pinny-password",
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when the end user username is wrong",
username: "wrong-username",
password: pinnyPassword,
provider: provider(nil),
wantError: `searching for user "wrong-username" resulted in 0 search results, but expected 1 result`,
name: "when the end user username is wrong",
username: "wrong-username",
password: pinnyPassword,
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when the user search filter does not compile",
@ -329,18 +332,18 @@ func TestLDAPSearch(t *testing.T) {
wantError: `error searching for user "pinny": LDAP Result Code 32 "No Such Object": `,
},
{
name: "when the search base causes no search results",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "ou=groups,dc=pinniped,dc=dev" }),
wantError: `searching for user "pinny" resulted in 0 search results, but expected 1 result`,
name: "when the search base causes no search results",
username: "pinny",
password: pinnyPassword,
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "ou=groups,dc=pinniped,dc=dev" }),
wantUnauthenticated: true,
},
{
name: "when there is no username specified",
username: "",
password: pinnyPassword,
provider: provider(nil),
wantError: `searching for user "" resulted in 0 search results, but expected 1 result`,
name: "when there is no username specified",
username: "",
password: pinnyPassword,
provider: provider(nil),
wantUnauthenticated: true,
},
{
name: "when there is no password specified",
@ -350,11 +353,11 @@ func TestLDAPSearch(t *testing.T) {
wantError: `error binding for user "pinny" using provided password against DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 206 "Empty password not allowed by the client": ldap: empty password not allowed by the client`,
},
{
name: "when the user has no password in their entry",
username: "olive",
password: "anything",
provider: provider(nil),
wantError: `error binding for user "olive" using provided password against DN "cn=olive,ou=users,dc=pinniped,dc=dev": LDAP Result Code 49 "Invalid Credentials": `,
name: "when the user has no password in their entry",
username: "olive",
password: "anything",
provider: provider(nil),
wantUnauthenticated: true,
},
}
@ -363,11 +366,16 @@ func TestLDAPSearch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
authResponse, authenticated, err := tt.provider.AuthenticateUser(ctx, tt.username, tt.password)
if tt.wantError != "" {
switch {
case tt.wantError != "":
require.EqualError(t, err, tt.wantError)
require.False(t, authenticated)
require.Nil(t, authResponse)
} else {
case tt.wantUnauthenticated:
require.NoError(t, err)
require.False(t, authenticated)
require.Nil(t, authResponse)
default:
require.NoError(t, err)
require.True(t, authenticated)
require.Equal(t, tt.wantAuthResponse, authResponse)
@ -486,7 +494,7 @@ func writeToNewTempFile(t *testing.T, dir string, filename string, contents []by
filePath := path.Join(dir, filename)
err := ioutil.WriteFile(filePath, contents, 0644)
err := ioutil.WriteFile(filePath, contents, 0600)
require.NoError(t, err)
t.Cleanup(func() {
@ -497,7 +505,7 @@ func writeToNewTempFile(t *testing.T, dir string, filename string, contents []by
var testLDIF = `
# ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! ***
# Here's a good explaination of LDIF:
# Here's a good explanation of LDIF:
# https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system
# pinniped.dev (organization, root)