feat: add management for ldap idp template (#5220)

Add management functionality for LDAP idps with templates and the basic functionality for the LDAP provider, which can then be used with a separate login page in the future.

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz
2023-02-15 09:14:59 +01:00
committed by GitHub
parent 058192c22b
commit 586495a0be
37 changed files with 7298 additions and 14 deletions

View File

@@ -0,0 +1,237 @@
package ldap
import (
"context"
"github.com/zitadel/zitadel/internal/idp"
)
const DefaultPort = "389"
var _ idp.Provider = (*Provider)(nil)
// Provider is the [idp.Provider] implementation for a generic LDAP provider
type Provider struct {
name string
host string
port string
tls bool
baseDN string
userObjectClass string
userUniqueAttribute string
admin string
password string
loginUrl string
isLinkingAllowed bool
isCreationAllowed bool
isAutoCreation bool
isAutoUpdate bool
idAttribute string
firstNameAttribute string
lastNameAttribute string
displayNameAttribute string
nickNameAttribute string
preferredUsernameAttribute string
emailAttribute string
emailVerifiedAttribute string
phoneAttribute string
phoneVerifiedAttribute string
preferredLanguageAttribute string
avatarURLAttribute string
profileAttribute string
}
type ProviderOpts func(provider *Provider)
// WithLinkingAllowed allows end users to link the federated user to an existing one.
func WithLinkingAllowed() ProviderOpts {
return func(p *Provider) {
p.isLinkingAllowed = true
}
}
// WithCreationAllowed allows end users to create a new user using the federated information.
func WithCreationAllowed() ProviderOpts {
return func(p *Provider) {
p.isCreationAllowed = true
}
}
// WithAutoCreation enables that federated users are automatically created if not already existing.
func WithAutoCreation() ProviderOpts {
return func(p *Provider) {
p.isAutoCreation = true
}
}
// WithAutoUpdate enables that information retrieved from the provider is automatically used to update
// the existing user on each authentication.
func WithAutoUpdate() ProviderOpts {
return func(p *Provider) {
p.isAutoUpdate = true
}
}
// WithCustomPort configures a custom port used for the communication instead of :389 as per default
func WithCustomPort(port string) ProviderOpts {
return func(p *Provider) {
p.port = port
}
}
// Insecure configures to communication insecure with the LDAP server without TLS
func Insecure() ProviderOpts {
return func(p *Provider) {
p.tls = false
}
}
// WithCustomIDAttribute configures to map the LDAP attribute to the user, default is the uniqueUserAttribute
func WithCustomIDAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.idAttribute = name
}
}
// WithFirstNameAttribute configures to map the LDAP attribute to the user
func WithFirstNameAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.firstNameAttribute = name
}
}
// WithLastNameAttribute configures to map the LDAP attribute to the user
func WithLastNameAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.lastNameAttribute = name
}
}
// WithDisplayNameAttribute configures to map the LDAP attribute to the user
func WithDisplayNameAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.displayNameAttribute = name
}
}
// WithNickNameAttribute configures to map the LDAP attribute to the user
func WithNickNameAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.nickNameAttribute = name
}
}
// WithPreferredUsernameAttribute configures to map the LDAP attribute to the user
func WithPreferredUsernameAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.preferredUsernameAttribute = name
}
}
// WithEmailAttribute configures to map the LDAP attribute to the user
func WithEmailAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.emailAttribute = name
}
}
// WithEmailVerifiedAttribute configures to map the LDAP attribute to the user
func WithEmailVerifiedAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.emailVerifiedAttribute = name
}
}
// WithPhoneAttribute configures to map the LDAP attribute to the user
func WithPhoneAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.phoneAttribute = name
}
}
// WithPhoneVerifiedAttribute configures to map the LDAP attribute to the user
func WithPhoneVerifiedAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.phoneVerifiedAttribute = name
}
}
// WithPreferredLanguageAttribute configures to map the LDAP attribute to the user
func WithPreferredLanguageAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.preferredLanguageAttribute = name
}
}
// WithAvatarURLAttribute configures to map the LDAP attribute to the user
func WithAvatarURLAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.avatarURLAttribute = name
}
}
// WithProfileAttribute configures to map the LDAP attribute to the user
func WithProfileAttribute(name string) ProviderOpts {
return func(p *Provider) {
p.profileAttribute = name
}
}
func New(
name string,
host string,
baseDN string,
userObjectClass string,
userUniqueAttribute string,
admin string,
password string,
loginUrl string,
options ...ProviderOpts,
) *Provider {
provider := &Provider{
name: name,
host: host,
port: DefaultPort,
tls: true,
baseDN: baseDN,
userObjectClass: userObjectClass,
userUniqueAttribute: userUniqueAttribute,
admin: admin,
password: password,
loginUrl: loginUrl,
idAttribute: userUniqueAttribute,
}
for _, option := range options {
option(provider)
}
return provider
}
func (p *Provider) Name() string {
return p.name
}
func (p *Provider) BeginAuth(ctx context.Context, state string, params ...any) (idp.Session, error) {
return &Session{
Provider: p,
loginUrl: p.loginUrl + "?state=" + state,
}, nil
}
func (p *Provider) IsLinkingAllowed() bool {
return p.isLinkingAllowed
}
func (p *Provider) IsCreationAllowed() bool {
return p.isCreationAllowed
}
func (p *Provider) IsAutoCreation() bool {
return p.isAutoCreation
}
func (p *Provider) IsAutoUpdate() bool {
return p.isAutoUpdate
}

View File

@@ -0,0 +1,185 @@
package ldap
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestProvider_Options(t *testing.T) {
type fields struct {
name string
host string
baseDN string
userObjectClass string
userUniqueAttribute string
admin string
password string
loginUrl string
opts []ProviderOpts
}
type want struct {
name string
port string
tls bool
linkingAllowed bool
creationAllowed bool
autoCreation bool
autoUpdate bool
idAttribute string
firstNameAttribute string
lastNameAttribute string
displayNameAttribute string
nickNameAttribute string
preferredUsernameAttribute string
emailAttribute string
emailVerifiedAttribute string
phoneAttribute string
phoneVerifiedAttribute string
preferredLanguageAttribute string
avatarURLAttribute string
profileAttribute string
}
tests := []struct {
name string
fields fields
want want
}{
{
name: "default",
fields: fields{
name: "ldap",
host: "host",
baseDN: "base",
userObjectClass: "class",
userUniqueAttribute: "attr",
admin: "admin",
password: "password",
loginUrl: "url",
opts: nil,
},
want: want{
name: "ldap",
port: DefaultPort,
tls: true,
linkingAllowed: false,
creationAllowed: false,
autoCreation: false,
autoUpdate: false,
idAttribute: "attr",
},
},
{
name: "all true",
fields: fields{
name: "ldap",
host: "host",
baseDN: "base",
userObjectClass: "class",
userUniqueAttribute: "attr",
admin: "admin",
password: "password",
loginUrl: "url",
opts: []ProviderOpts{
WithLinkingAllowed(),
WithCreationAllowed(),
WithAutoCreation(),
WithAutoUpdate(),
},
},
want: want{
name: "ldap",
port: DefaultPort,
tls: true,
linkingAllowed: true,
creationAllowed: true,
autoCreation: true,
autoUpdate: true,
idAttribute: "attr",
},
}, {
name: "all true, attributes set",
fields: fields{
name: "ldap",
host: "host",
baseDN: "base",
userObjectClass: "class",
userUniqueAttribute: "attr",
admin: "admin",
password: "password",
loginUrl: "url",
opts: []ProviderOpts{
Insecure(),
WithCustomPort("port"),
WithLinkingAllowed(),
WithCreationAllowed(),
WithAutoCreation(),
WithAutoUpdate(),
WithCustomIDAttribute("id"),
WithFirstNameAttribute("first"),
WithLastNameAttribute("last"),
WithDisplayNameAttribute("display"),
WithNickNameAttribute("nick"),
WithPreferredUsernameAttribute("prefUser"),
WithEmailAttribute("email"),
WithEmailVerifiedAttribute("emailVerified"),
WithPhoneAttribute("phone"),
WithPhoneVerifiedAttribute("phoneVerified"),
WithPreferredLanguageAttribute("prefLang"),
WithAvatarURLAttribute("avatar"),
WithProfileAttribute("profile"),
},
},
want: want{
name: "ldap",
port: "port",
tls: false,
linkingAllowed: true,
creationAllowed: true,
autoCreation: true,
autoUpdate: true,
idAttribute: "id",
firstNameAttribute: "first",
lastNameAttribute: "last",
displayNameAttribute: "display",
nickNameAttribute: "nick",
preferredUsernameAttribute: "prefUser",
emailAttribute: "email",
emailVerifiedAttribute: "emailVerified",
phoneAttribute: "phone",
phoneVerifiedAttribute: "phoneVerified",
preferredLanguageAttribute: "prefLang",
avatarURLAttribute: "avatar",
profileAttribute: "profile",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := assert.New(t)
provider := New(tt.fields.name, tt.fields.host, tt.fields.baseDN, tt.fields.userObjectClass, tt.fields.userUniqueAttribute, tt.fields.admin, tt.fields.password, tt.fields.loginUrl, tt.fields.opts...)
a.Equal(tt.want.name, provider.Name())
a.Equal(tt.want.port, provider.port)
a.Equal(tt.want.tls, provider.tls)
a.Equal(tt.want.linkingAllowed, provider.IsLinkingAllowed())
a.Equal(tt.want.creationAllowed, provider.IsCreationAllowed())
a.Equal(tt.want.autoCreation, provider.IsAutoCreation())
a.Equal(tt.want.autoUpdate, provider.IsAutoUpdate())
a.Equal(tt.want.idAttribute, provider.idAttribute)
a.Equal(tt.want.firstNameAttribute, provider.firstNameAttribute)
a.Equal(tt.want.lastNameAttribute, provider.lastNameAttribute)
a.Equal(tt.want.displayNameAttribute, provider.displayNameAttribute)
a.Equal(tt.want.nickNameAttribute, provider.nickNameAttribute)
a.Equal(tt.want.preferredUsernameAttribute, provider.preferredUsernameAttribute)
a.Equal(tt.want.emailAttribute, provider.emailAttribute)
a.Equal(tt.want.emailVerifiedAttribute, provider.emailVerifiedAttribute)
a.Equal(tt.want.phoneAttribute, provider.phoneAttribute)
a.Equal(tt.want.phoneVerifiedAttribute, provider.phoneVerifiedAttribute)
a.Equal(tt.want.preferredLanguageAttribute, provider.preferredLanguageAttribute)
a.Equal(tt.want.avatarURLAttribute, provider.avatarURLAttribute)
a.Equal(tt.want.profileAttribute, provider.profileAttribute)
})
}
}

View File

@@ -0,0 +1,98 @@
package ldap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"github.com/go-ldap/ldap/v3"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/idp"
)
var ErrNoSingleUser = errors.New("user does not exist or too many entries returned")
var _ idp.Session = (*Session)(nil)
type Session struct {
Provider *Provider
loginUrl string
user string
password string
}
func (s *Session) GetAuthURL() string {
return s.loginUrl
}
func (s *Session) FetchUser(_ context.Context) (idp.User, error) {
l, err := ldap.DialURL("ldap://" + s.Provider.host + ":" + s.Provider.port)
if err != nil {
return nil, err
}
defer l.Close()
if s.Provider.tls {
err = l.StartTLS(&tls.Config{ServerName: s.Provider.host})
if err != nil {
return nil, err
}
}
// Bind as the admin to search for user
err = l.Bind("cn="+s.Provider.admin+","+s.Provider.baseDN, s.Provider.password)
if err != nil {
return nil, err
}
// Search for user with the unique attribute for the userDN
searchRequest := ldap.NewSearchRequest(
s.Provider.baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&(objectClass="+s.Provider.userObjectClass+")("+s.Provider.userUniqueAttribute+"=%s))", ldap.EscapeFilter(s.user)),
[]string{"dn"},
nil,
)
sr, err := l.Search(searchRequest)
if err != nil {
return nil, err
}
if len(sr.Entries) != 1 {
return nil, ErrNoSingleUser
}
user := sr.Entries[0]
// Bind as the user to verify their password
err = l.Bind(user.DN, s.password)
if err != nil {
return nil, err
}
emailVerified, err := strconv.ParseBool(user.GetAttributeValue(s.Provider.emailVerifiedAttribute))
if err != nil {
return nil, err
}
phoneVerified, err := strconv.ParseBool(user.GetAttributeValue(s.Provider.phoneVerifiedAttribute))
if err != nil {
return nil, err
}
return NewUser(
user.GetAttributeValue(s.Provider.idAttribute),
user.GetAttributeValue(s.Provider.firstNameAttribute),
user.GetAttributeValue(s.Provider.lastNameAttribute),
user.GetAttributeValue(s.Provider.displayNameAttribute),
user.GetAttributeValue(s.Provider.nickNameAttribute),
user.GetAttributeValue(s.Provider.preferredUsernameAttribute),
user.GetAttributeValue(s.Provider.emailAttribute),
emailVerified,
user.GetAttributeValue(s.Provider.phoneAttribute),
phoneVerified,
language.Make(user.GetAttributeValue(s.Provider.preferredLanguageAttribute)),
user.GetAttributeValue(s.Provider.avatarURLAttribute),
user.GetAttributeValue(s.Provider.profileAttribute),
), nil
}

View File

@@ -0,0 +1,91 @@
package ldap
import "golang.org/x/text/language"
type User struct {
id string
firstName string
lastName string
displayName string
nickName string
preferredUsername string
email string
emailVerified bool
phone string
phoneVerified bool
preferredLanguage language.Tag
avatarURL string
profile string
}
func NewUser(
id string,
firstName string,
lastName string,
displayName string,
nickName string,
preferredUsername string,
email string,
emailVerified bool,
phone string,
phoneVerified bool,
preferredLanguage language.Tag,
avatarURL string,
profile string,
) *User {
return &User{
id,
firstName,
lastName,
displayName,
nickName,
preferredUsername,
email,
emailVerified,
phone,
phoneVerified,
preferredLanguage,
avatarURL,
profile,
}
}
func (u *User) GetID() string {
return u.id
}
func (u *User) GetFirstName() string {
return u.firstName
}
func (u *User) GetLastName() string {
return u.lastName
}
func (u *User) GetDisplayName() string {
return u.displayName
}
func (u *User) GetNickname() string {
return u.nickName
}
func (u *User) GetPreferredUsername() string {
return u.preferredUsername
}
func (u *User) GetEmail() string {
return u.email
}
func (u *User) IsEmailVerified() bool {
return u.emailVerified
}
func (u *User) GetPhone() string {
return u.phone
}
func (u *User) IsPhoneVerified() bool {
return u.phoneVerified
}
func (u *User) GetPreferredLanguage() language.Tag {
return u.preferredLanguage
}
func (u *User) GetAvatarURL() string {
return u.avatarURL
}
func (u *User) GetProfile() string {
return u.profile
}