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:
parent
fec3d92f26
commit
51263a0f07
@ -95,10 +95,11 @@ func handleAuthRequestForLDAPUpstream(
|
|||||||
|
|
||||||
authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password)
|
authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password)
|
||||||
if err != nil {
|
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")
|
return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication")
|
||||||
}
|
}
|
||||||
if !authenticated {
|
if !authenticated {
|
||||||
|
plog.Debug("failed upstream LDAP authentication", "upstreamName", ldapUpstream.GetName())
|
||||||
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
|
// 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."))
|
err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."))
|
||||||
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
|
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
|
||||||
|
@ -15,12 +15,15 @@ import (
|
|||||||
"github.com/go-ldap/ldap/v3"
|
"github.com/go-ldap/ldap/v3"
|
||||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||||
"k8s.io/apiserver/pkg/authentication/user"
|
"k8s.io/apiserver/pkg/authentication/user"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/plog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ldapsScheme = "ldaps"
|
ldapsScheme = "ldaps"
|
||||||
distinguishedNameAttributeName = "dn"
|
distinguishedNameAttributeName = "dn"
|
||||||
userSearchFilterInterpolationLocationMarker = "{}"
|
userSearchFilterInterpolationLocationMarker = "{}"
|
||||||
|
invalidCredentialsErrorPrefix = `LDAP Result Code 49 "Invalid Credentials":`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Conn abstracts the upstream LDAP communication protocol (mostly for testing).
|
// 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"`)
|
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)
|
conn, err := p.dial(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, fmt.Errorf(`error dialing host "%s": %w`, p.Host, err)
|
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 {
|
if err != nil {
|
||||||
return nil, false, err
|
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{
|
response := &authenticator.Response{
|
||||||
User: &user.DefaultInfo{
|
User: &user.DefaultInfo{
|
||||||
@ -216,7 +228,12 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, password string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf(`error searching for user "%s": %w`, username, err)
|
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`,
|
return "", "", fmt.Errorf(`searching for user "%s" resulted in %d search results, but expected 1 result`,
|
||||||
username, len(searchResult.Entries),
|
username, len(searchResult.Entries),
|
||||||
)
|
)
|
||||||
@ -236,9 +253,14 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, password string
|
|||||||
return "", "", err
|
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)
|
err = conn.Bind(userEntry.DN, password)
|
||||||
if err != nil {
|
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)
|
return "", "", fmt.Errorf(`error binding for user "%s" using provided password against DN "%s": %w`, username, userEntry.DN, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -89,6 +89,7 @@ func TestAuthenticateUser(t *testing.T) {
|
|||||||
wantError string
|
wantError string
|
||||||
wantToSkipDial bool
|
wantToSkipDial bool
|
||||||
wantAuthResponse *authenticator.Response
|
wantAuthResponse *authenticator.Response
|
||||||
|
wantUnauthenticated bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "happy path",
|
name: "happy path",
|
||||||
@ -282,6 +283,8 @@ func TestAuthenticateUser(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "when dial fails",
|
name: "when dial fails",
|
||||||
|
username: testUpstreamUsername,
|
||||||
|
password: testUpstreamPassword,
|
||||||
provider: provider(nil),
|
provider: provider(nil),
|
||||||
dialError: errors.New("some dial error"),
|
dialError: errors.New("some dial error"),
|
||||||
wantError: fmt.Sprintf(`error dialing host "%s": some dial error`, testHost),
|
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",
|
name: "when binding as the bind user returns an error",
|
||||||
|
username: testUpstreamUsername,
|
||||||
|
password: testUpstreamPassword,
|
||||||
provider: provider(nil),
|
provider: provider(nil),
|
||||||
setupMocks: func(conn *mockldapconn.MockConn) {
|
setupMocks: func(conn *mockldapconn.MockConn) {
|
||||||
conn.EXPECT().Bind(testBindUsername, testBindPassword).Return(errors.New("some bind error")).Times(1)
|
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)
|
}, nil).Times(1)
|
||||||
conn.EXPECT().Close().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",
|
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),
|
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 {
|
for _, test := range tests {
|
||||||
@ -551,11 +587,16 @@ func TestAuthenticateUser(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, !tt.wantToSkipDial, dialWasAttempted)
|
require.Equal(t, !tt.wantToSkipDial, dialWasAttempted)
|
||||||
|
|
||||||
if tt.wantError != "" {
|
switch {
|
||||||
|
case tt.wantError != "":
|
||||||
require.EqualError(t, err, tt.wantError)
|
require.EqualError(t, err, tt.wantError)
|
||||||
require.False(t, authenticated)
|
require.False(t, authenticated)
|
||||||
require.Nil(t, authResponse)
|
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.NoError(t, err)
|
||||||
require.True(t, authenticated)
|
require.True(t, authenticated)
|
||||||
require.Equal(t, tt.wantAuthResponse, authResponse)
|
require.Equal(t, tt.wantAuthResponse, authResponse)
|
||||||
|
@ -14,7 +14,7 @@ stringData:
|
|||||||
#@yaml/text-templated-strings
|
#@yaml/text-templated-strings
|
||||||
ldap.ldif: |
|
ldap.ldif: |
|
||||||
# ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! ***
|
# ** 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
|
# https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system
|
||||||
|
|
||||||
# pinniped.dev (organization, root)
|
# pinniped.dev (organization, root)
|
||||||
|
@ -24,6 +24,8 @@ import (
|
|||||||
"go.pinniped.dev/internal/upstreamldap"
|
"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) {
|
func TestLDAPSearch(t *testing.T) {
|
||||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@ -63,6 +65,7 @@ func TestLDAPSearch(t *testing.T) {
|
|||||||
provider *upstreamldap.Provider
|
provider *upstreamldap.Provider
|
||||||
wantError string
|
wantError string
|
||||||
wantAuthResponse *authenticator.Response
|
wantAuthResponse *authenticator.Response
|
||||||
|
wantUnauthenticated bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "happy path",
|
name: "happy path",
|
||||||
@ -197,14 +200,14 @@ func TestLDAPSearch(t *testing.T) {
|
|||||||
username: "pinny",
|
username: "pinny",
|
||||||
password: "wrong-pinny-password",
|
password: "wrong-pinny-password",
|
||||||
provider: provider(nil),
|
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": `,
|
wantUnauthenticated: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "when the end user username is wrong",
|
name: "when the end user username is wrong",
|
||||||
username: "wrong-username",
|
username: "wrong-username",
|
||||||
password: pinnyPassword,
|
password: pinnyPassword,
|
||||||
provider: provider(nil),
|
provider: provider(nil),
|
||||||
wantError: `searching for user "wrong-username" resulted in 0 search results, but expected 1 result`,
|
wantUnauthenticated: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "when the user search filter does not compile",
|
name: "when the user search filter does not compile",
|
||||||
@ -333,14 +336,14 @@ func TestLDAPSearch(t *testing.T) {
|
|||||||
username: "pinny",
|
username: "pinny",
|
||||||
password: pinnyPassword,
|
password: pinnyPassword,
|
||||||
provider: provider(func(p *upstreamldap.Provider) { p.UserSearch.Base = "ou=groups,dc=pinniped,dc=dev" }),
|
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`,
|
wantUnauthenticated: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "when there is no username specified",
|
name: "when there is no username specified",
|
||||||
username: "",
|
username: "",
|
||||||
password: pinnyPassword,
|
password: pinnyPassword,
|
||||||
provider: provider(nil),
|
provider: provider(nil),
|
||||||
wantError: `searching for user "" resulted in 0 search results, but expected 1 result`,
|
wantUnauthenticated: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "when there is no password specified",
|
name: "when there is no password specified",
|
||||||
@ -354,7 +357,7 @@ func TestLDAPSearch(t *testing.T) {
|
|||||||
username: "olive",
|
username: "olive",
|
||||||
password: "anything",
|
password: "anything",
|
||||||
provider: provider(nil),
|
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": `,
|
wantUnauthenticated: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,11 +366,16 @@ func TestLDAPSearch(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
authResponse, authenticated, err := tt.provider.AuthenticateUser(ctx, tt.username, tt.password)
|
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.EqualError(t, err, tt.wantError)
|
||||||
require.False(t, authenticated)
|
require.False(t, authenticated)
|
||||||
require.Nil(t, authResponse)
|
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.NoError(t, err)
|
||||||
require.True(t, authenticated)
|
require.True(t, authenticated)
|
||||||
require.Equal(t, tt.wantAuthResponse, authResponse)
|
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)
|
filePath := path.Join(dir, filename)
|
||||||
|
|
||||||
err := ioutil.WriteFile(filePath, contents, 0644)
|
err := ioutil.WriteFile(filePath, contents, 0600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
@ -497,7 +505,7 @@ func writeToNewTempFile(t *testing.T, dir string, filename string, contents []by
|
|||||||
|
|
||||||
var testLDIF = `
|
var testLDIF = `
|
||||||
# ** CAUTION: Blank lines separate entries in the LDIF format! Do not remove them! ***
|
# ** 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
|
# https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system
|
||||||
|
|
||||||
# pinniped.dev (organization, root)
|
# pinniped.dev (organization, root)
|
Loading…
Reference in New Issue
Block a user