Use base64 binary-encoded value as UID for LDAP

This is to allow the use of binary LDAP entry attributes as the UID.
For example, a user might like to configure AD’s objectGUID or maybe
objectSid attributes as the UID attribute.

This negatively impacts the readability of the UID when it did not come
from a binary value, but we're considering this an okay trade-off to
keep things simple for now. In the future, we may offer more
customizable encoding options for binary attributes.

These UIDs are currently only used in the downstream OIDC `sub` claim.
They do not effect the user's identity on the Kubernetes cluster,
which is only based on their mapped username and group memberships from
the upstream identity provider. We are not currently supporting any
special encoding for those username and group name LDAP attributes, so
their values in the LDAP entry must be ASCII or UTF-8 in order for them
to be interpreted correctly.
This commit is contained in:
Ryan Richard 2021-05-27 13:47:10 -07:00
parent 35cf1a00c8
commit d2251d2ea7
4 changed files with 67 additions and 26 deletions

View File

@ -8,6 +8,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -417,7 +418,9 @@ func (p *Provider) searchAndBindUser(conn Conn, username string, bindFunc func(c
return "", "", nil, err return "", "", nil, err
} }
mappedUID, err := p.getSearchResultAttributeValue(p.c.UserSearch.UIDAttribute, userEntry, username) // We would like to support binary typed attributes for UIDs, so always read them as binary and encode them,
// even when the attribute may not be binary.
mappedUID, err := p.getSearchResultAttributeRawValueEncoded(p.c.UserSearch.UIDAttribute, userEntry, username)
if err != nil { if err != nil {
return "", "", nil, err return "", "", nil, err
} }
@ -526,6 +529,30 @@ func (p *Provider) escapeUsernameForSearchFilter(username string) string {
return ldap.EscapeFilter(username) return ldap.EscapeFilter(username)
} }
// Returns the (potentially) binary data of the attribute's value, base64 URL encoded.
func (p *Provider) getSearchResultAttributeRawValueEncoded(attributeName string, entry *ldap.Entry, username string) (string, error) {
if attributeName == distinguishedNameAttributeName {
return base64.RawURLEncoding.EncodeToString([]byte(entry.DN)), nil
}
attributeValues := entry.GetRawAttributeValues(attributeName)
if len(attributeValues) != 1 {
return "", fmt.Errorf(`found %d values for attribute "%s" while searching for user "%s", but expected 1 result`,
len(attributeValues), attributeName, username,
)
}
attributeValue := attributeValues[0]
if len(attributeValue) == 0 {
return "", fmt.Errorf(`found empty value for attribute "%s" while searching for user "%s", but expected value to be non-empty`,
attributeName, username,
)
}
return base64.RawURLEncoding.EncodeToString(attributeValue), nil
}
func (p *Provider) getSearchResultAttributeValue(attributeName string, entry *ldap.Entry, username string) (string, error) { func (p *Provider) getSearchResultAttributeValue(attributeName string, entry *ldap.Entry, username string) (string, error) {
if attributeName == distinguishedNameAttributeName { if attributeName == distinguishedNameAttributeName {
return entry.DN, nil return entry.DN, nil

View File

@ -6,6 +6,7 @@ package upstreamldap
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -153,7 +154,7 @@ func TestEndUserAuthentication(t *testing.T) {
expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticator.Response { expectedAuthResponse := func(editFunc func(r *user.DefaultInfo)) *authenticator.Response {
u := &user.DefaultInfo{ u := &user.DefaultInfo{
Name: testUserSearchResultUsernameAttributeValue, Name: testUserSearchResultUsernameAttributeValue,
UID: testUserSearchResultUIDAttributeValue, UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2}, Groups: []string{testGroupSearchResultGroupNameAttributeValue1, testGroupSearchResultGroupNameAttributeValue2},
} }
if editFunc != nil { if editFunc != nil {
@ -311,7 +312,7 @@ func TestEndUserAuthentication(t *testing.T) {
conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1) conn.EXPECT().Bind(testUserSearchResultDNValue, testUpstreamPassword).Times(1)
}, },
wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) { wantAuthResponse: expectedAuthResponse(func(r *user.DefaultInfo) {
r.UID = testUserSearchResultDNValue r.UID = base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultDNValue))
}), }),
}, },
{ {
@ -477,7 +478,7 @@ func TestEndUserAuthentication(t *testing.T) {
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{ User: &user.DefaultInfo{
Name: testUserSearchResultUsernameAttributeValue, Name: testUserSearchResultUsernameAttributeValue,
UID: testUserSearchResultUIDAttributeValue, UID: base64.RawURLEncoding.EncodeToString([]byte(testUserSearchResultUIDAttributeValue)),
Groups: []string{"a", "b", "c"}, Groups: []string{"a", "b", "c"},
}, },
}, },

View File

@ -5,6 +5,7 @@ package integration
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -58,6 +59,10 @@ func TestLDAPSearch(t *testing.T) {
pinnyPassword := env.SupervisorUpstreamLDAP.TestUserPassword pinnyPassword := env.SupervisorUpstreamLDAP.TestUserPassword
b64 := func(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
tests := []struct { tests := []struct {
name string name string
username string username string
@ -73,7 +78,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(nil)), provider: upstreamldap.New(*providerConfig(nil)),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -85,7 +90,7 @@ func TestLDAPSearch(t *testing.T) {
p.ConnectionProtocol = upstreamldap.StartTLS p.ConnectionProtocol = upstreamldap.StartTLS
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -94,7 +99,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Base = "dc=pinniped,dc=dev" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -103,7 +108,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.Filter = "(cn={})" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -115,7 +120,7 @@ func TestLDAPSearch(t *testing.T) {
p.UserSearch.Filter = "cn={}" p.UserSearch.Filter = "cn={}"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "cn=pinny,ou=users,dc=pinniped,dc=dev", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -126,7 +131,7 @@ func TestLDAPSearch(t *testing.T) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -137,7 +142,7 @@ func TestLDAPSearch(t *testing.T) {
p.UserSearch.Filter = "(|(cn={})(mail={}))" p.UserSearch.Filter = "(|(cn={})(mail={}))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -146,7 +151,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "dn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "cn=pinny,ou=users,dc=pinniped,dc=dev", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("cn=pinny,ou=users,dc=pinniped,dc=dev"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -155,7 +160,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UIDAttribute = "sn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "Seal", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("Seal"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -164,7 +169,7 @@ func TestLDAPSearch(t *testing.T) {
password: pinnyPassword, password: pinnyPassword,
provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })), provider: upstreamldap.New(*providerConfig(func(p *upstreamldap.ProviderConfig) { p.UserSearch.UsernameAttribute = "sn" })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "Seal", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, // note that the final answer has case preserved from the entry User: &user.DefaultInfo{Name: "Seal", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}}, // note that the final answer has case preserved from the entry
}, },
}, },
{ {
@ -177,7 +182,7 @@ func TestLDAPSearch(t *testing.T) {
p.UserSearch.UIDAttribute = "givenName" p.UserSearch.UIDAttribute = "givenName"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: "Pinny the 🦭", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "Pinny the 🦭", UID: b64("Pinny the 🦭"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -189,7 +194,7 @@ func TestLDAPSearch(t *testing.T) {
p.UserSearch.UsernameAttribute = "cn" p.UserSearch.UsernameAttribute = "cn"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, },
}, },
{ {
@ -210,7 +215,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.Base = "" p.GroupSearch.Base = ""
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}},
}, },
}, },
{ {
@ -221,7 +226,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree p.GroupSearch.Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}},
}, },
}, },
{ {
@ -232,7 +237,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.GroupNameAttribute = "dn" p.GroupSearch.GroupNameAttribute = "dn"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{ User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev", "cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev",
"cn=seals,ou=groups,dc=pinniped,dc=dev", "cn=seals,ou=groups,dc=pinniped,dc=dev",
}}, }},
@ -246,7 +251,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test p.GroupSearch.GroupNameAttribute = "objectClass" // silly example, but still a meaningful test
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"groupOfNames", "groupOfNames"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"groupOfNames", "groupOfNames"}},
}, },
}, },
{ {
@ -257,7 +262,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))" p.GroupSearch.Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))"
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"seals"}},
}, },
}, },
{ {
@ -268,7 +273,7 @@ func TestLDAPSearch(t *testing.T) {
p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema p.GroupSearch.Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema
})), })),
wantAuthResponse: &authenticator.Response{ wantAuthResponse: &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{}},
}, },
}, },
{ {
@ -593,7 +598,7 @@ func TestLDAPSearch(t *testing.T) {
} }
} }
func TestSimultaneousRequestsOnSingleProvider(t *testing.T) { func TestSimultaneousLDAPRequestsOnSingleProvider(t *testing.T) {
env := library.IntegrationEnv(t) env := library.IntegrationEnv(t)
// Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml. // Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml.
@ -614,6 +619,10 @@ func TestSimultaneousRequestsOnSingleProvider(t *testing.T) {
provider := upstreamldap.New(*defaultProviderConfig(env, ldapHostPort)) provider := upstreamldap.New(*defaultProviderConfig(env, ldapHostPort))
b64 := func(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}
// Making multiple simultaneous requests on the same upstreamldap.Provider instance should all succeed // Making multiple simultaneous requests on the same upstreamldap.Provider instance should all succeed
// without triggering the race detector. // without triggering the race detector.
iterations := 150 iterations := 150
@ -639,7 +648,7 @@ func TestSimultaneousRequestsOnSingleProvider(t *testing.T) {
assert.NoError(t, result.err) assert.NoError(t, result.err)
assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not") assert.True(t, result.authenticated, "expected the user to be authenticated, but they were not")
assert.Equal(t, &authenticator.Response{ assert.Equal(t, &authenticator.Response{
User: &user.DefaultInfo{Name: "pinny", UID: "1000", Groups: []string{"ball-game-players", "seals"}}, User: &user.DefaultInfo{Name: "pinny", UID: b64("1000"), Groups: []string{"ball-game-players", "seals"}},
}, result.response) }, result.response)
} }
} }

View File

@ -119,7 +119,9 @@ func TestSupervisorLogin(t *testing.T) {
}, },
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(
"ldaps://" + env.SupervisorUpstreamLDAP.Host + "?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) + "&sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue, "ldaps://" + env.SupervisorUpstreamLDAP.Host +
"?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) +
"&sub=" + base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
), ),
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue), wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue),
@ -176,7 +178,9 @@ func TestSupervisorLogin(t *testing.T) {
}, },
// the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute
wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta( wantDownstreamIDTokenSubjectToMatch: regexp.QuoteMeta(
"ldaps://" + env.SupervisorUpstreamLDAP.StartTLSOnlyHost + "?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) + "&sub=" + env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue, "ldaps://" + env.SupervisorUpstreamLDAP.StartTLSOnlyHost +
"?base=" + url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase) +
"&sub=" + base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)),
), ),
// the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute
wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN), wantDownstreamIDTokenUsernameToMatch: regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN),