From da9b4620b3a379f70cfd6763ab3a4e5fa3defb2b Mon Sep 17 00:00:00 2001 From: Margo Crawford Date: Thu, 28 Oct 2021 12:00:56 -0700 Subject: [PATCH] Active Directory checks whether password has changed recently during upstream refresh Signed-off-by: Margo Crawford --- .../active_directory_upstream_watcher.go | 1 + .../active_directory_upstream_watcher_test.go | 24 ++ .../provider/dynamic_upstream_idp_provider.go | 10 +- internal/oidc/token/token_handler.go | 42 ++- internal/oidc/token/token_handler_test.go | 43 ++- .../testutil/oidctestutil/oidctestutil.go | 8 +- internal/upstreamldap/upstreamldap.go | 73 +++- internal/upstreamldap/upstreamldap_test.go | 180 +++++++++- test/integration/supervisor_login_test.go | 325 ++++++++++++++---- test/testlib/env.go | 2 + 10 files changed, 612 insertions(+), 96 deletions(-) diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index 7bd83f39..0fbd2167 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -317,6 +317,7 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, }, Dialer: c.ldapDialer, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, } if spec.GroupSearch.Attributes.GroupName == "" { diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index da204216..3baa47b7 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -221,6 +221,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, } // Make a copy with targeted changes. @@ -537,6 +538,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -593,6 +595,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: "sAMAccountName", }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -652,6 +655,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -711,6 +715,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantErr: controllerlib.ErrSyntheticRequeue.Error(), @@ -769,6 +774,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -898,6 +904,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1022,6 +1029,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1073,6 +1081,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1273,6 +1282,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": upstreamldap.GroupSAMAccountNameWithDomainSuffix}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1325,6 +1335,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1381,6 +1392,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1431,6 +1443,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1627,6 +1640,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": upstreamldap.MicrosoftUUIDFromBinary("objectGUID")}, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{"pwdLastSet": upstreamldap.PwdUnchangedSinceLogin}, }, }, wantResultingUpstreams: []v1alpha1.ActiveDirectoryIdentityProvider{{ @@ -1753,6 +1767,16 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualGroupAttributeParsingOverrides[k]).Pointer()) } + expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks + actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks + copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{} + actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error{} + require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks)) + for k, v := range expectedRefreshAttributeChecks { + require.NotNil(t, actualRefreshAttributeChecks[k]) + require.Equal(t, reflect.ValueOf(v).Pointer(), reflect.ValueOf(actualRefreshAttributeChecks[k]).Pointer()) + } + require.Equal(t, copyOfExpectedValueForResultingCache, actualConfig) } diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/oidc/provider/dynamic_upstream_idp_provider.go index a054a4c8..1fd75cf2 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/oidc/provider/dynamic_upstream_idp_provider.go @@ -7,6 +7,7 @@ import ( "context" "net/url" "sync" + "time" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" @@ -93,7 +94,14 @@ type UpstreamLDAPIdentityProviderI interface { authenticators.UserAuthenticator // PerformRefresh performs a refresh against the upstream LDAP identity provider - PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error + PerformRefresh(ctx context.Context, storedRefreshAttributes StoredRefreshAttributes) error +} + +type StoredRefreshAttributes struct { + Username string + Subject string + DN string + AuthTime time.Time } type DynamicUpstreamIDPProvider interface { diff --git a/internal/oidc/token/token_handler.go b/internal/oidc/token/token_handler.go index fcaf4110..d578959b 100644 --- a/internal/oidc/token/token_handler.go +++ b/internal/oidc/token/token_handler.go @@ -75,11 +75,6 @@ func NewHandler( func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, providerCache oidc.UpstreamIdentityProvidersLister) error { session := accessRequest.GetSession().(*psession.PinnipedSession) - downstreamUsername, err := getDownstreamUsernameFromPinnipedSession(session) - if err != nil { - return err - } - downstreamSubject := session.Fosite.Claims.Subject customSessionData := session.Custom if customSessionData == nil { @@ -95,9 +90,9 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, case psession.ProviderTypeOIDC: return upstreamOIDCRefresh(ctx, customSessionData, providerCache) case psession.ProviderTypeLDAP: - return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) + return upstreamLDAPRefresh(ctx, providerCache, session) case psession.ProviderTypeActiveDirectory: - return upstreamLDAPRefresh(ctx, customSessionData, providerCache, downstreamUsername, downstreamSubject) + return upstreamLDAPRefresh(ctx, providerCache, session) default: return errorsx.WithStack(errMissingUpstreamSessionInternalError) } @@ -169,7 +164,15 @@ func findOIDCProviderByNameAndValidateUID( WithHint("Provider from upstream session data was not found.").WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) } -func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, providerCache oidc.UpstreamIdentityProvidersLister, username string, subject string) error { +func upstreamLDAPRefresh(ctx context.Context, providerCache oidc.UpstreamIdentityProvidersLister, session *psession.PinnipedSession) error { + username, err := getDownstreamUsernameFromPinnipedSession(session) + if err != nil { + return err + } + subject := session.Fosite.Claims.Subject + + s := session.Custom + // if you have neither a valid ldap session config nor a valid active directory session config validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != "" validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != "" @@ -182,8 +185,16 @@ func upstreamLDAPRefresh(ctx context.Context, s *psession.CustomSessionData, pro if err != nil { return err } + if session.IDTokenClaims().AuthTime.IsZero() { + return errorsx.WithStack(errMissingUpstreamSessionInternalError) + } // run PerformRefresh - err = p.PerformRefresh(ctx, dn, username, subject) + err = p.PerformRefresh(ctx, provider.StoredRefreshAttributes{ + Username: username, + Subject: subject, + DN: dn, + AuthTime: session.IDTokenClaims().AuthTime, + }) if err != nil { return errorsx.WithStack(errUpstreamRefreshError.WithHint( "Upstream refresh failed.").WithWrap(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)) @@ -235,16 +246,3 @@ func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) } return downstreamUsername, nil } - -func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) (string, error) { - extra := session.Fosite.Claims.Extra - if extra == nil { - return "", errorsx.WithStack(errMissingUpstreamSessionInternalError) - } - downstreamUsernameInterface := extra["username"] - if downstreamUsernameInterface == nil { - return "", errorsx.WithStack(errMissingUpstreamSessionInternalError) - } - downstreamUsername := downstreamUsernameInterface.(string) - return downstreamUsername, nil -} diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 77e6efd1..fa799dbe 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -2181,6 +2181,45 @@ func TestRefreshGrant(t *testing.T) { }, }, }, + { + name: "auth time is the zero value", // time.Times can never be nil, but it is possible that it would be the zero value which would mean something's wrong + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ + Name: ldapUpstreamName, + ResourceUID: ldapUpstreamResourceUID, + URL: ldapUpstreamURL, + }), + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + customSessionData: happyLDAPCustomSessionData, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess( + happyLDAPCustomSessionData, + ), + }, + modifyRefreshTokenStorage: func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) { + refreshTokenSignature := getFositeDataSignature(t, refreshToken) + firstRequester, err := oauthStore.GetRefreshTokenSession(context.Background(), refreshTokenSignature, nil) + require.NoError(t, err) + session := firstRequester.GetSession().(*psession.PinnipedSession) + fositeSessionClaims := session.Fosite.IDTokenClaims() + fositeSessionClaims.AuthTime = time.Time{} + session.Fosite.Claims = fositeSessionClaims + err = oauthStore.DeleteRefreshTokenSession(context.Background(), refreshTokenSignature) + require.NoError(t, err) + err = oauthStore.CreateRefreshTokenSession(context.Background(), refreshTokenSignature, firstRequester) + require.NoError(t, err) + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusInternalServerError, + wantErrorResponseBody: here.Doc(` + { + "error": "error", + "error_description": "There was an internal server error. Required upstream data not found in session." + } + `), + }, + }, + }, { name: "when the ldap provider in the session storage is found but has the wrong resource UID during the refresh request", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{ @@ -2201,7 +2240,7 @@ func TestRefreshGrant(t *testing.T) { wantErrorResponseBody: here.Doc(` { "error": "error", - "error_description": "Error during upstream refresh. Provider 'some-ldap-idp' of type 'ldap' from upstream session data has changed its resource UID since authentication." + "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." } `), }, @@ -2227,7 +2266,7 @@ func TestRefreshGrant(t *testing.T) { wantErrorResponseBody: here.Doc(` { "error": "error", - "error_description": "Error during upstream refresh. Provider 'some-ad-idp' of type 'activedirectory' from upstream session data has changed its resource UID since authentication." + "error_description": "Error during upstream refresh. Provider from upstream session data has changed its resource UID since authentication." } `), }, diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 03603bdb..0af6e429 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -111,16 +111,16 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { return u.URL } -func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { +func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) error { if u.performRefreshArgs == nil { u.performRefreshArgs = make([]*PerformRefreshArgs, 0) } u.performRefreshCallCount++ u.performRefreshArgs = append(u.performRefreshArgs, &PerformRefreshArgs{ Ctx: ctx, - DN: userDN, - ExpectedUsername: expectedUsername, - ExpectedSubject: expectedSubject, + DN: storedRefreshAttributes.DN, + ExpectedUsername: storedRefreshAttributes.Username, + ExpectedSubject: storedRefreshAttributes.Subject, }) if u.PerformRefreshErr != nil { return u.PerformRefreshErr diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index b69b37354..9c7c678a 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -15,6 +15,7 @@ import ( "net/url" "regexp" "sort" + "strconv" "strings" "time" @@ -40,6 +41,7 @@ const ( defaultLDAPPort = uint16(389) defaultLDAPSPort = uint16(636) sAMAccountNameAttribute = "sAMAccountName" + pwdLastSetAttribute = "pwdLastSet" ) // Conn abstracts the upstream LDAP communication protocol (mostly for testing). @@ -119,6 +121,9 @@ type ProviderConfig struct { // GroupNameMappingOverrides are the mappings between an attribute name and a way to parse it as a group // name when it comes out of LDAP. GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error) + + // RefreshAttributeChecks are extra checks that attributes in a refresh response are as expected. + RefreshAttributeChecks map[string]func(*ldap.Entry, provider.StoredRefreshAttributes) error } // UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. @@ -170,9 +175,11 @@ func (p *Provider) GetConfig() ProviderConfig { return p.c } -func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, expectedSubject string) error { +func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.StoredRefreshAttributes) error { t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()}) defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches + userDN := storedRefreshAttributes.DN + searchResult, err := p.performRefresh(ctx, userDN) if err != nil { p.traceRefreshFailure(t, err) @@ -196,9 +203,9 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, if err != nil { return err } - if newUsername != expectedUsername { + if newUsername != storedRefreshAttributes.Username { return fmt.Errorf(`searching for user "%s" returned a different username than the previous value. expected: "%s", actual: "%s"`, - userDN, expectedUsername, newUsername, + userDN, storedRefreshAttributes.Username, newUsername, ) } @@ -207,10 +214,15 @@ func (p *Provider) PerformRefresh(ctx context.Context, userDN, expectedUsername, return err } newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL()) - if newSubject != expectedSubject { - return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, expectedSubject, newSubject) + if newSubject != storedRefreshAttributes.Subject { + return fmt.Errorf(`searching for user "%s" produced a different subject than the previous value. expected: "%s", actual: "%s"`, userDN, storedRefreshAttributes.Subject, newSubject) + } + for attribute, validateFunc := range p.c.RefreshAttributeChecks { + err = validateFunc(userEntry, storedRefreshAttributes) + if err != nil { + return fmt.Errorf(`validation for attribute "%s" failed during upstream refresh: %w`, attribute, err) + } } - // we checked that the user still exists and their information is the same, so just return. return nil } @@ -652,7 +664,7 @@ func (p *Provider) refreshUserSearchRequest(dn string) *ldap.SearchRequest { TimeLimit: 90, TypesOnly: false, Filter: "(objectClass=*)", // we already have the dn, so the filter doesn't matter - Attributes: p.userSearchRequestedAttributes(), + Attributes: p.refreshAttributes(), Controls: nil, // this could be used to enable paging, but we're already limiting the result max size } } @@ -679,6 +691,14 @@ func (p *Provider) groupSearchRequestedAttributes() []string { } } +func (p *Provider) refreshAttributes() []string { + attributes := p.userSearchRequestedAttributes() + for k := range p.c.RefreshAttributeChecks { + attributes = append(attributes, k) + } + return attributes +} + func (p *Provider) userSearchFilter(username string) string { safeUsername := p.escapeUsernameForSearchFilter(username) if len(p.c.UserSearch.Filter) == 0 { @@ -836,3 +856,42 @@ func getDomainFromDistinguishedName(distinguishedName string) (string, error) { } return strings.Join(domainComponents[1:], "."), nil } + +func PwdUnchangedSinceLogin(entry *ldap.Entry, attributes provider.StoredRefreshAttributes) error { + authTime := attributes.AuthTime + pwdLastSetWin32Format := entry.GetAttributeValues(pwdLastSetAttribute) + if len(pwdLastSetWin32Format) != 1 { + return fmt.Errorf("expected to find 1 value for %s attribute, but found %d", pwdLastSetAttribute, len(pwdLastSetWin32Format)) + } + // convert to a time.Time + pwdLastSetParsed, err := win32timestampToTime(pwdLastSetWin32Format[0]) + if err != nil { + return err + } + + // if pwdLastSet > authTime, that means that the password has been changed since the initial login. + // return an error so the user is prompted to log in again. + if pwdLastSetParsed.After(authTime) { + return fmt.Errorf("password has changed since login. login time: %s, password set time: %s", authTime, pwdLastSetParsed) + } + return nil +} + +func win32timestampToTime(win32timestamp string) (*time.Time, error) { + // take a win32 timestamp (represented as the number of 100 ns intervals since + // January 1, 1601) and make a time.Time + + const unixTimeBaseAsWin = 116444736000000000 // The unix base time (January 1, 1970 UTC) as 100 ns since Win32 epoch (1601-01-01) + const hundredNsToSecFactor = 10000000 + + win32Time, err := strconv.ParseUint(win32timestamp, 10, 64) + if err != nil { + return nil, fmt.Errorf("couldn't parse as timestamp") + } + + unixsec := int64(win32Time-unixTimeBaseAsWin) / hundredNsToSecFactor + unixns := int64(win32Time % hundredNsToSecFactor) + + convertedTime := time.Unix(unixsec, unixns).UTC() + return &convertedTime, nil +} diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go index ca2f3e6b..9e74ce43 100644 --- a/internal/upstreamldap/upstreamldap_test.go +++ b/internal/upstreamldap/upstreamldap_test.go @@ -16,6 +16,8 @@ import ( "testing" "time" + provider2 "go.pinniped.dev/internal/oidc/provider" + "github.com/go-ldap/ldap/v3" "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" @@ -1217,6 +1219,7 @@ func TestEndUserAuthentication(t *testing.T) { } func TestUpstreamRefresh(t *testing.T) { + pwdLastSetAttribute := "pwdLastSet" expectedUserSearch := &ldap.SearchRequest{ BaseDN: testUserSearchResultDNValue, Scope: ldap.ScopeBaseObject, @@ -1225,7 +1228,7 @@ func TestUpstreamRefresh(t *testing.T) { TimeLimit: 90, TypesOnly: false, Filter: "(objectClass=*)", - Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute}, + Attributes: []string{testUserSearchUsernameAttribute, testUserSearchUIDAttribute, pwdLastSetAttribute}, Controls: nil, // don't need paging because we set the SizeLimit so small } @@ -1242,6 +1245,10 @@ func TestUpstreamRefresh(t *testing.T) { Name: testUserSearchUIDAttribute, ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, }, + { + Name: pwdLastSetAttribute, + Values: []string{"132801740800000000"}, + }, }, }, }, @@ -1259,6 +1266,7 @@ func TestUpstreamRefresh(t *testing.T) { UIDAttribute: testUserSearchUIDAttribute, UsernameAttribute: testUserSearchUsernameAttribute, }, + RefreshAttributeChecks: map[string]func(*ldap.Entry, provider2.StoredRefreshAttributes) error{pwdLastSetAttribute: PwdUnchangedSinceLogin}, } tests := []struct { @@ -1512,6 +1520,36 @@ func TestUpstreamRefresh(t *testing.T) { }, wantErr: "found 2 values for attribute \"some-upstream-uid-attribute\" while searching for user \"some-upstream-user-dn\", but expected 1 result", }, + { + name: "search result has a recent pwdLastSet value", + providerConfig: providerConfig, + setupMocks: func(conn *mockldapconn.MockConn) { + conn.EXPECT().Bind(testBindUsername, testBindPassword).Times(1) + conn.EXPECT().Search(expectedUserSearch).Return(&ldap.SearchResult{ + Entries: []*ldap.Entry{ + { + DN: testUserSearchResultDNValue, + Attributes: []*ldap.EntryAttribute{ + { + Name: testUserSearchUsernameAttribute, + Values: []string{testUserSearchResultUsernameAttributeValue}, + }, + { + Name: testUserSearchUIDAttribute, + ByteValues: [][]byte{[]byte(testUserSearchResultUIDAttributeValue)}, + }, + { + Name: pwdLastSetAttribute, + Values: []string{"132803468800000000"}, + }, + }, + }, + }, + }, nil).Times(1) + conn.EXPECT().Close().Times(1) + }, + wantErr: "validation for attribute \"pwdLastSet\" failed during upstream refresh: password has changed since login. login time: 2021-11-01 23:43:19 +0000 UTC, password set time: 2021-11-02 17:14:40 +0000 UTC", + }, } for _, tt := range tests { @@ -1538,7 +1576,13 @@ func TestUpstreamRefresh(t *testing.T) { provider := New(*providerConfig) subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU" - err := provider.PerformRefresh(context.Background(), testUserSearchResultDNValue, testUserSearchResultUsernameAttributeValue, subject) + authTime := time.Date(2021, time.November, 1, 23, 43, 19, 0, time.UTC) + err := provider.PerformRefresh(context.Background(), provider2.StoredRefreshAttributes{ + Username: testUserSearchResultUsernameAttributeValue, + Subject: subject, + DN: testUserSearchResultDNValue, + AuthTime: authTime, + }) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) @@ -1916,3 +1960,135 @@ func TestGetDomainFromDistinguishedName(t *testing.T) { }) } } + +func TestPwdUnchangedSinceLogin(t *testing.T) { + authTime := "2021-11-01T23:43:19.826433579Z" // this is the format that fosite automatically stores + authTimeParsed, err := time.Parse(time.RFC3339Nano, authTime) + require.NoError(t, err) + pwdResetTimeAfterAuthTime := "132803468800000000" // Nov 2 + pwdResetTimeBeforeAuthTime := "132801740800000000" // Oct 31 + tests := []struct { + name string + authTime *time.Time + entry *ldap.Entry + wantResult bool + wantErr string + }{ + { + name: "happy path where password has not been reset since login", + authTime: &authTimeParsed, + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "pwdLastSet", + Values: []string{pwdResetTimeBeforeAuthTime}, + }, + }, + }, + }, + { + name: "password has been reset since login", + authTime: &authTimeParsed, + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "pwdLastSet", + Values: []string{pwdResetTimeAfterAuthTime}, + }, + }, + }, + wantErr: "password has changed since login. login time: 2021-11-01 23:43:19.826433579 +0000 UTC, password set time: 2021-11-02 17:14:40 +0000 UTC", + }, + { + name: "ldap timestamp is in the wrong format", + authTime: &authTimeParsed, + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "pwdLastSet", + Values: []string{"invalid"}, + }, + }, + }, + wantErr: "couldn't parse as timestamp", + }, + { + name: "no value for pwdLastSet attribute", + authTime: &authTimeParsed, + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{}, + }, + wantErr: "expected to find 1 value for pwdLastSet attribute, but found 0", + }, + { + name: "too many values for pwdLastSet attribute", + authTime: &authTimeParsed, + entry: &ldap.Entry{ + DN: "some-dn", + Attributes: []*ldap.EntryAttribute{ + { + Name: "pwdLastSet", + Values: []string{"val1", "val2"}, + }, + }, + }, + wantErr: "expected to find 1 value for pwdLastSet attribute, but found 2", + }, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + err := PwdUnchangedSinceLogin(tt.entry, provider2.StoredRefreshAttributes{AuthTime: *tt.authTime}) + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestWin32TimestampToTime(t *testing.T) { + happyPasswordChangeTime := time.Date(2021, time.January, 2, 0, 12, 21, 0, time.UTC).UTC() + tests := []struct { + name string + timestampString string + wantTime *time.Time + wantErr string + }{ + { + name: "happy case with a valid timestamp", + timestampString: "132540199410000000", + wantTime: &happyPasswordChangeTime, + }, + { + name: "handles error with a string thats not a timestamp", + timestampString: "not timestamp", + wantErr: "couldn't parse as timestamp", + }, + { + name: "handles error with too big of a timestamp", + timestampString: "132540199410000000000", + wantErr: "couldn't parse as timestamp", + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + actualTime, err := win32timestampToTime(tt.timestampString) + require.Equal(t, tt.wantTime, actualTime) + if tt.wantErr != "" { + require.Error(t, err) + require.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 43b45e78..e8d18b5c 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -5,11 +5,16 @@ package integration import ( "context" + "crypto/rand" "crypto/tls" + "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io/ioutil" + "math/big" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,9 +24,11 @@ import ( "time" coreosoidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-ldap/ldap/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" + "golang.org/x/text/encoding/unicode" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -44,18 +51,19 @@ func TestSupervisorLogin(t *testing.T) { tests := []struct { name string maybeSkip func(t *testing.T) + createTestUser func(t *testing.T) (string, string) + deleteTestUser func(t *testing.T, username string) + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) createIDP func(t *testing.T) string - requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) wantDownstreamIDTokenSubjectToMatch string - wantDownstreamIDTokenUsernameToMatch string + wantDownstreamIDTokenUsernameToMatch func(username string) string wantDownstreamIDTokenGroups []string wantErrorDescription string wantErrorType string - // We don't necessarily have any way to revoke the user's session on the upstream provider, - // so to cause the upstream refresh to fail we can cheat by manipulating the user's session + // Either revoke the user's session on the upstream provider, or manipulate the user's session // data in such a way that it should cause the next upstream refresh attempt to fail. - breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName string) + breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) }{ { name: "oidc with default username and groups claim settings", @@ -76,7 +84,7 @@ func TestSupervisorLogin(t *testing.T) { return oidcIDP.Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) @@ -85,7 +93,7 @@ func TestSupervisorLogin(t *testing.T) { // the ID token Subject should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", // the ID token Username should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, { name: "oidc with custom username and groups claim settings", @@ -113,14 +121,14 @@ func TestSupervisorLogin(t *testing.T) { return oidcIDP.Name }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlow, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" }, wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, { @@ -144,7 +152,7 @@ func TestSupervisorLogin(t *testing.T) { }, idpv1alpha1.PhaseReady) return oidcIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamOIDC.Username, // username to present to server during login @@ -153,7 +161,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeOIDC, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) @@ -162,7 +170,7 @@ func TestSupervisorLogin(t *testing.T) { // the ID token Subject should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", // the ID token Username should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, { name: "ldap with email as username and groups names as DNs and using an LDAP provider which supports TLS", @@ -212,7 +220,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -221,7 +229,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -235,8 +243,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "ldap with CN as username and group names as CNs and using an LDAP provider which only supports StartTLS", // try another variation of configuration options @@ -286,7 +296,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserCN, // username to present to server during login @@ -295,7 +305,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -309,7 +319,7 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs, }, { @@ -360,7 +370,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -438,7 +448,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -447,7 +457,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -460,8 +470,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "ldap login still works after deleting and recreating the bind secret", @@ -543,7 +555,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -552,7 +564,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeLDAP, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.LDAP.UserDN) @@ -565,8 +577,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, { name: "activedirectory with all default options", @@ -604,7 +618,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -613,7 +627,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -627,8 +641,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { name: "activedirectory with custom options", maybeSkip: func(t *testing.T) { @@ -679,7 +695,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue, // username to present to server during login @@ -688,7 +704,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -702,8 +718,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs, }, { name: "active directory login still works after updating bind secret", @@ -759,7 +777,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -768,7 +786,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -781,8 +799,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, { name: "active directory login still works after deleting and recreating bind secret", @@ -853,7 +873,7 @@ func TestSupervisorLogin(t *testing.T) { }, time.Minute, 500*time.Millisecond) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login @@ -862,7 +882,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _ string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) @@ -875,8 +895,72 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + }, + { + name: "active directory login fails after the user password is changed", + maybeSkip: func(t *testing.T) { + t.Helper() + if len(env.ToolsNamespace) == 0 && !env.HasCapability(testlib.CanReachInternetLDAPPorts) { + t.Skip("LDAP integration test requires connectivity to an LDAP server") + } + if env.SupervisorUpstreamActiveDirectory.Host == "" { + t.Skip("Active Directory hostname not specified") + } + }, + createIDP: func(t *testing.T) string { + t.Helper() + secret := testlib.CreateTestSecret(t, env.SupervisorNamespace, "ad-service-account", v1.SecretTypeBasicAuth, + map[string]string{ + v1.BasicAuthUsernameKey: env.SupervisorUpstreamActiveDirectory.BindUsername, + v1.BasicAuthPasswordKey: env.SupervisorUpstreamActiveDirectory.BindPassword, + }, + ) + adIDP := testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + Host: env.SupervisorUpstreamActiveDirectory.Host, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), + }, + Bind: idpv1alpha1.ActiveDirectoryIdentityProviderBind{ + SecretName: secret.Name, + }, + }, idpv1alpha1.ActiveDirectoryPhaseReady) + expectedMsg := fmt.Sprintf( + `successfully able to connect to "%s" and bind as user "%s" [validated with Secret "%s" at version "%s"]`, + env.SupervisorUpstreamActiveDirectory.Host, env.SupervisorUpstreamActiveDirectory.BindUsername, + secret.Name, secret.ResourceVersion, + ) + requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) + return adIDP.Name + }, + createTestUser: func(t *testing.T) (string, string) { + return createFreshADTestUser(t, env) + }, + deleteTestUser: func(t *testing.T, username string) { + deleteTestADUser(t, env, username) + }, + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + testUserName, // username to present to server during login + testUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { + changeADTestUserPassword(t, env, username) // this will fail for now + }, + // we can't know the subject ahead of time because we created a new user and don't know their uid, + // so skip wantDownstreamIDTokenSubjectToMatch + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(username string) string { + return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" + }, + wantDownstreamIDTokenGroups: []string{}, // none for now. }, { name: "logging in to activedirectory with a deactivated user fails", @@ -914,7 +998,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulActiveDirectoryIdentityProviderConditions(t, adIDP, expectedMsg) return adIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamActiveDirectory.TestDeactivatedUserSAMAccountNameValue, // username to present to server during login @@ -975,7 +1059,7 @@ func TestSupervisorLogin(t *testing.T) { requireSuccessfulLDAPIdentityProviderConditions(t, ldapIDP, expectedMsg) return ldapIDP.Name }, - requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _ string, httpClient *http.Client) { + requestAuthorization: func(t *testing.T, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login @@ -984,7 +1068,7 @@ func TestSupervisorLogin(t *testing.T) { false, ) }, - breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string) { + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, _ string) { // get the idp, update the config. client := testlib.NewSupervisorClientset(t) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) @@ -1007,8 +1091,10 @@ func TestSupervisorLogin(t *testing.T) { "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), ) + "$", // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute - wantDownstreamIDTokenUsernameToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$", - wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + wantDownstreamIDTokenUsernameToMatch: func(username string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, } for _, test := range tests { @@ -1020,6 +1106,8 @@ func TestSupervisorLogin(t *testing.T) { tt.createIDP, tt.requestAuthorization, tt.breakRefreshSessionData, + tt.createTestUser, + tt.deleteTestUser, tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, @@ -1150,10 +1238,15 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes func testSupervisorLogin( t *testing.T, createIDP func(t *testing.T) string, - requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client), - breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string), - wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, - wantErrorDescription string, wantErrorType string, + requestAuthorization func(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client), + breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), + createTestUser func(t *testing.T) (string, string), + deleteTestUser func(t *testing.T, username string), + wantDownstreamIDTokenSubjectToMatch string, + wantDownstreamIDTokenUsernameToMatch func(username string) string, + wantDownstreamIDTokenGroups []string, + wantErrorDescription string, + wantErrorType string, ) { env := testlib.IntegrationEnv(t) @@ -1241,6 +1334,12 @@ func testSupervisorLogin( // Create upstream IDP and wait for it to become ready. idpName := createIDP(t) + username, password := "", "" + if createTestUser != nil { + username, password = createTestUser(t) + defer deleteTestUser(t, username) + } + // Perform OIDC discovery for our downstream. var discovery *coreosoidc.Provider testlib.RequireEventually(t, func(requireEventually *require.Assertions) { @@ -1276,7 +1375,7 @@ func testSupervisorLogin( ) // Perform parameterized auth code acquisition. - requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, httpClient) + requestAuthorization(t, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) // Expect that our callback handler was invoked. callback := localCallbackServer.waitForCallback(10 * time.Second) @@ -1294,7 +1393,7 @@ func testSupervisorLogin( expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username", "groups"} verifyTokenResponse(t, tokenResponse, discovery, downstreamOAuth2Config, nonceParam, - expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) // token exchange on the original token doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) @@ -1308,7 +1407,7 @@ func testSupervisorLogin( expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "groups", "at_hash"} verifyTokenResponse(t, refreshedTokenResponse, discovery, downstreamOAuth2Config, "", - expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch, wantDownstreamIDTokenGroups) + expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) @@ -1333,7 +1432,7 @@ func testSupervisorLogin( // Next mutate the part of the session that is used during upstream refresh. pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) require.True(t, ok, "should have been able to cast session data to PinnipedSession") - breakRefreshSessionData(t, pinnipedSession, idpName) + breakRefreshSessionData(t, pinnipedSession, idpName, username) // Then save the mutated Secret back to Kubernetes. // There is no update function, so delete and create again at the same name. @@ -1423,7 +1522,7 @@ func verifyTokenResponse( require.NotEmpty(t, tokenResponse.RefreshToken) } -func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL string, httpClient *http.Client) { +func requestAuthorizationUsingBrowserAuthcodeFlow(t *testing.T, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { t.Helper() env := testlib.IntegrationEnv(t) @@ -1595,3 +1694,113 @@ func expectSecurityHeaders(t *testing.T, response *http.Response, expectFositeTo assert.Equal(t, "no-cache", h.Get("Pragma")) assert.Equal(t, "0", h.Get("Expires")) } + +// create a fresh test user in AD to use for this test. +func createFreshADTestUser(t *testing.T, env *testlib.TestEnv) (string, string) { + t.Helper() + // dial tls + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + testUserName := "user-" + createRandomHexString(t, 7) // sAMAccountNames are limited to 20 characters, so this is as long as we can make it. + // create + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + a := ldap.NewAddRequest(userDN, []ldap.Control{}) + a.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + a.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", testUserName, env.SupervisorUpstreamActiveDirectory.Domain)}) + a.Attribute("sAMAccountName", []string{testUserName}) + + err = conn.Add(a) + require.NoError(t, err) + + // modify password and enable account + testUserPassword := createRandomASCIIString(t, 20) + enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + encodedTestUserPassword, err := enc.String("\"" + testUserPassword + "\"") + require.NoError(t, err) + + m := ldap.NewModifyRequest(userDN, []ldap.Control{}) + m.Replace("unicodePwd", []string{encodedTestUserPassword}) + m.Replace("userAccountControl", []string{"512"}) + err = conn.Modify(m) + require.NoError(t, err) + return testUserName, testUserPassword +} + +// change the user's password to a new one. +func changeADTestUserPassword(t *testing.T, env *testlib.TestEnv, testUserName string) { + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + newTestUserPassword := createRandomASCIIString(t, 20) + enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() + encodedTestUserPassword, err := enc.String("\"" + newTestUserPassword + "\"") + require.NoError(t, err) + + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + m := ldap.NewModifyRequest(userDN, []ldap.Control{}) + m.Replace("unicodePwd", []string{encodedTestUserPassword}) + err = conn.Modify(m) + require.NoError(t, err) + // don't bother to return the new password... we won't be using it, just checking that it's changed. +} + +// delete the test user created for this test. +func deleteTestADUser(t *testing.T, env *testlib.TestEnv, testUserName string) { + t.Helper() + + conn := dialTLS(t, env) + // bind + err := conn.Bind(env.SupervisorUpstreamActiveDirectory.BindUsername, env.SupervisorUpstreamActiveDirectory.BindPassword) + require.NoError(t, err) + + userDN := fmt.Sprintf("CN=%s,OU=test-users,%s", testUserName, env.SupervisorUpstreamActiveDirectory.UserSearchBase) + d := ldap.NewDelRequest(userDN, []ldap.Control{}) + err = conn.Del(d) + require.NoError(t, err) +} + +func dialTLS(t *testing.T, env *testlib.TestEnv) *ldap.Conn { + t.Helper() + // dial tls + rootCAs := x509.NewCertPool() + success := rootCAs.AppendCertsFromPEM([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)) + require.True(t, success) + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: rootCAs} + dialer := &tls.Dialer{NetDialer: &net.Dialer{Timeout: time.Minute}, Config: tlsConfig} + c, err := dialer.DialContext(context.Background(), "tcp", env.SupervisorUpstreamActiveDirectory.Host) + require.NoError(t, err) + conn := ldap.NewConn(c, true) + conn.Start() + return conn +} + +func createRandomHexString(t *testing.T, length int) string { + t.Helper() + bytes := make([]byte, length) + _, err := rand.Read(bytes) + require.NoError(t, err) + randomString := hex.EncodeToString(bytes) + return randomString +} + +func createRandomASCIIString(t *testing.T, length int) string { + result := "" + for { + if len(result) >= length { + return result + } + num, err := rand.Int(rand.Reader, big.NewInt(int64(127))) + require.NoError(t, err) + n := num.Int64() + // Make sure that the number/byte/letter is inside + // the range of printable ASCII characters (excluding space and DEL) + if n > 32 && n < 127 { + result += string(rune(n)) + } + } +} diff --git a/test/testlib/env.go b/test/testlib/env.go index f12b0163..7ce6d0be 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -84,6 +84,7 @@ type TestOIDCUpstream struct { type TestLDAPUpstream struct { Host string `json:"host"` + Domain string `json:"domain"` StartTLSOnlyHost string `json:"startTLSOnlyHost"` CABundle string `json:"caBundle"` BindUsername string `json:"bindUsername"` @@ -279,6 +280,7 @@ func loadEnvVars(t *testing.T, result *TestEnv) { result.SupervisorUpstreamActiveDirectory = TestLDAPUpstream{ Host: wantEnv("PINNIPED_TEST_AD_HOST", ""), + Domain: wantEnv("PINNIPED_TEST_AD_DOMAIN", ""), CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_AD_LDAPS_CA_BUNDLE")), BindUsername: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_USERNAME", ""), BindPassword: wantEnv("PINNIPED_TEST_AD_BIND_ACCOUNT_PASSWORD", ""),