diff --git a/cmd/wg-portal/common/config.go b/cmd/wg-portal/common/config.go index b9b2dd6..a50ccaa 100644 --- a/cmd/wg-portal/common/config.go +++ b/cmd/wg-portal/common/config.go @@ -3,22 +3,49 @@ import ( "os" + "github.com/go-ldap/ldap/v3" "github.com/h44z/wg-portal/internal/persistence" "github.com/h44z/wg-portal/internal/portal" "gopkg.in/yaml.v3" ) -type OauthFields struct { +type BaseFields struct { UserIdentifier string `yaml:"user_identifier"` Email string `yaml:"email"` Firstname string `yaml:"firstname"` Lastname string `yaml:"lastname"` Phone string `yaml:"phone"` Department string `yaml:"department"` - IsAdmin string `yaml:"is_admin"` } -type LdapAuthProvider struct { +type OauthFields struct { + BaseFields `yaml:",inline"` + IsAdmin string `yaml:"is_admin"` +} + +type LdapFields struct { + BaseFields `yaml:",inline"` + GroupMembership string `yaml:"memberof"` +} + +type LdapProvider struct { + URL string `yaml:"url"` + StartTLS bool `yaml:"start_tls"` + CertValidation bool `yaml:"cert_validation"` + BaseDN string `yaml:"base_dn"` + BindUser string `yaml:"bind_user"` + BindPass string `yaml:"bind_pass"` + + FieldMap LdapFields `yaml:"field_map"` + + LoginFilter string `yaml:"login_filter"` // {{login_identifier}} gets replaced with the login email address + AdminGroupDN string `yaml:"admin_group"` // Members of this group receive admin rights in WG-Portal + adminGroupDN *ldap.DN `yaml:"-"` + + Synchronize bool `yaml:"synchronize"` + DeleteMissing bool `yaml:"delete_missing"` // if DeleteMissing is false, missing users will be deactivated + SyncFilter string `yaml:"sync_filter"` + RegistrationEnabled bool `yaml:"registration_enabled"` } type OpenIDConnectProvider struct { @@ -69,7 +96,7 @@ // Scope specifies optional requested permissions. Scopes []string `yaml:"scopes"` - // Fielmap contains + // Fieldmap contains FieldMap OauthFields `yaml:"field_map"` // If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database. @@ -102,7 +129,7 @@ Auth struct { OpenIDConnect []OpenIDConnectProvider `yaml:"openIdCconnect"` OAuth []OAuthProvider `yaml:"oauth"` - Ldap []LdapAuthProvider `yaml:"ldap"` + Ldap []LdapProvider `yaml:"ldap"` } `yaml:"auth"` Mail portal.MailConfig `yaml:"email"` diff --git a/cmd/wg-portal/common/ldap.go b/cmd/wg-portal/common/ldap.go new file mode 100644 index 0000000..ad85f2d --- /dev/null +++ b/cmd/wg-portal/common/ldap.go @@ -0,0 +1,306 @@ +package common + +import ( + "context" + "crypto/tls" + "strings" + + "github.com/pkg/errors" + + "github.com/go-ldap/ldap/v3" + + "github.com/h44z/wg-portal/internal/persistence" + "github.com/h44z/wg-portal/internal/user" +) + +type LdapAuthenticator interface { + user.Authenticator + GetAllUserInfos(ctx context.Context) ([]map[string]interface{}, error) + GetUserInfo(ctx context.Context, username persistence.UserIdentifier) (map[string]interface{}, error) + ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) + RegistrationEnabled() bool + SynchronizationEnabled() bool +} + +type ldapAuthenticator struct { + cfg *LdapProvider +} + +func NewLdapAuthenticator(_ context.Context, cfg *LdapProvider) (*ldapAuthenticator, error) { + var authenticator = &ldapAuthenticator{} + + authenticator.cfg = cfg + + dn, err := ldap.ParseDN(cfg.AdminGroupDN) + if err != nil { + return nil, errors.WithMessage(err, "failed to parse admin group DN") + } + authenticator.cfg.FieldMap = getLdapFieldMapping(cfg.FieldMap) + authenticator.cfg.adminGroupDN = dn + + return authenticator, nil +} + +func (l *ldapAuthenticator) RegistrationEnabled() bool { + return l.cfg.RegistrationEnabled +} + +func (l *ldapAuthenticator) SynchronizationEnabled() bool { + return l.cfg.Synchronize +} + +func (l *ldapAuthenticator) PlaintextAuthentication(userId persistence.UserIdentifier, plainPassword string) error { + conn, err := l.connect() + if err != nil { + return errors.WithMessage(err, "failed to setup connection") + } + defer l.disconnect(conn) + + attrs := []string{"dn"} + + loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1) + searchRequest := ldap.NewSearchRequest( + l.cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit + loginFilter, attrs, nil, + ) + + sr, err := conn.Search(searchRequest) + if err != nil { + return errors.Wrapf(err, "failed to search in ldap") + } + + if len(sr.Entries) == 0 { + return errors.New("user not found") + } + + if len(sr.Entries) > 1 { + return errors.New("no unique user found") + } + + // Bind as the user to verify their password + userDN := sr.Entries[0].DN + err = conn.Bind(userDN, plainPassword) + if err != nil { + return errors.Wrapf(err, "invalid credentials") + } + _ = conn.Unbind() + + return nil +} + +func (l *ldapAuthenticator) HashedAuthentication(_ persistence.UserIdentifier, _ string) error { + // TODO: is this possible? + return errors.New("unimplemented") +} + +func (l *ldapAuthenticator) GetUserInfo(_ context.Context, userId persistence.UserIdentifier) (map[string]interface{}, error) { + conn, err := l.connect() + if err != nil { + return nil, errors.WithMessage(err, "failed to setup connection") + } + defer l.disconnect(conn) + + attrs := l.getLdapSearchAttributes() + + loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1) + searchRequest := ldap.NewSearchRequest( + l.cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit + loginFilter, attrs, nil, + ) + + sr, err := conn.Search(searchRequest) + if err != nil { + return nil, errors.Wrapf(err, "failed to search in ldap") + } + + if len(sr.Entries) == 0 { + return nil, errors.New("user not found") + } + + if len(sr.Entries) > 1 { + return nil, errors.New("no unique user found") + } + + users := l.convertLdapEntries(sr) + + return users[0], nil +} + +func (l *ldapAuthenticator) GetAllUserInfos(_ context.Context) ([]map[string]interface{}, error) { + conn, err := l.connect() + if err != nil { + return nil, errors.WithMessage(err, "failed to setup connection") + } + defer l.disconnect(conn) + + attrs := l.getLdapSearchAttributes() + + searchRequest := ldap.NewSearchRequest( + l.cfg.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit + l.cfg.SyncFilter, attrs, nil, + ) + + sr, err := conn.Search(searchRequest) + if err != nil { + return nil, errors.Wrapf(err, "failed to search in ldap") + } + + users := l.convertLdapEntries(sr) + + return users, nil +} + +func (l *ldapAuthenticator) convertLdapEntries(sr *ldap.SearchResult) []map[string]interface{} { + users := make([]map[string]interface{}, len(sr.Entries)) + + fieldMap := l.cfg.FieldMap + for i, entry := range sr.Entries { + userData := make(map[string]interface{}) + userData[fieldMap.UserIdentifier] = entry.DN + userData[fieldMap.Email] = entry.GetAttributeValue(fieldMap.Email) + userData[fieldMap.Firstname] = entry.GetAttributeValue(fieldMap.Firstname) + userData[fieldMap.Lastname] = entry.GetAttributeValue(fieldMap.Lastname) + userData[fieldMap.Phone] = entry.GetAttributeValue(fieldMap.Phone) + userData[fieldMap.Department] = entry.GetAttributeValue(fieldMap.Department) + userData[fieldMap.GroupMembership] = entry.GetRawAttributeValues(fieldMap.GroupMembership) + + users[i] = userData + } + return users +} + +func (l *ldapAuthenticator) getLdapSearchAttributes() []string { + fieldMap := l.cfg.FieldMap + attrs := []string{"dn", fieldMap.UserIdentifier} + if fieldMap.Email != "" { + attrs = append(attrs, fieldMap.Email) + } + if fieldMap.Firstname != "" { + attrs = append(attrs, fieldMap.Firstname) + } + if fieldMap.Lastname != "" { + attrs = append(attrs, fieldMap.Lastname) + } + if fieldMap.Phone != "" { + attrs = append(attrs, fieldMap.Phone) + } + if fieldMap.Department != "" { + attrs = append(attrs, fieldMap.Department) + } + if fieldMap.GroupMembership != "" { + attrs = append(attrs, fieldMap.GroupMembership) + } + + return uniqueStringSlice(attrs) +} + +func (l ldapAuthenticator) ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) { + isAdmin, err := userIsInAdminGroup(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.adminGroupDN) + if err != nil { + return nil, errors.WithMessage(err, "failed to check admin group") + } + userInfo := &AuthenticatorUserInfo{ + Identifier: persistence.UserIdentifier(mapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")), + Email: mapDefaultString(raw, l.cfg.FieldMap.Email, ""), + Firstname: mapDefaultString(raw, l.cfg.FieldMap.Firstname, ""), + Lastname: mapDefaultString(raw, l.cfg.FieldMap.Lastname, ""), + Phone: mapDefaultString(raw, l.cfg.FieldMap.Phone, ""), + Department: mapDefaultString(raw, l.cfg.FieldMap.Department, ""), + IsAdmin: isAdmin, + } + + return userInfo, nil +} + +func (l *ldapAuthenticator) connect() (*ldap.Conn, error) { + tlsConfig := &tls.Config{InsecureSkipVerify: !l.cfg.CertValidation} + conn, err := ldap.DialURL(l.cfg.URL, ldap.DialWithTLSConfig(tlsConfig)) + if err != nil { + return nil, errors.Wrap(err, "failed to connect to LDAP") + } + + if l.cfg.StartTLS { // Reconnect with TLS + if err = conn.StartTLS(tlsConfig); err != nil { + return nil, errors.Wrap(err, "failed to start TLS on connection") + } + } + + if err = conn.Bind(l.cfg.BindUser, l.cfg.BindPass); err != nil { + return nil, errors.Wrap(err, "failed to bind to LDAP") + } + + return conn, nil +} + +func (l *ldapAuthenticator) disconnect(conn *ldap.Conn) { + if conn != nil { + conn.Close() + } +} + +func userIsInAdminGroup(groupData [][]byte, adminGroupDN *ldap.DN) (bool, error) { + for _, group := range groupData { + dn, err := ldap.ParseDN(string(group)) + if err != nil { + return false, errors.WithMessage(err, "failed to parse group DN") + } + if adminGroupDN.Equal(dn) { + return true, nil + } + } + + return false, nil +} + +func getLdapFieldMapping(f LdapFields) LdapFields { + defaultMap := LdapFields{ + BaseFields: BaseFields{ + UserIdentifier: "mail", + Email: "mail", + Firstname: "givenName", + Lastname: "sn", + Phone: "telephoneNumber", + Department: "department", + }, + GroupMembership: "memberOf", + } + if f.UserIdentifier != "" { + defaultMap.UserIdentifier = f.UserIdentifier + } + if f.Email != "" { + defaultMap.Email = f.Email + } + if f.Firstname != "" { + defaultMap.Firstname = f.Firstname + } + if f.Lastname != "" { + defaultMap.Lastname = f.Lastname + } + if f.Phone != "" { + defaultMap.Phone = f.Phone + } + if f.Department != "" { + defaultMap.Department = f.Department + } + if f.GroupMembership != "" { + defaultMap.GroupMembership = f.GroupMembership + } + + return defaultMap +} + +// uniqueStringSlice removes duplicates in the given string slice +func uniqueStringSlice(slice []string) []string { + keys := make(map[string]struct{}) + uniqueSlice := make([]string, 0, len(slice)) + for _, entry := range slice { + if _, exists := keys[entry]; !exists { + keys[entry] = struct{}{} + uniqueSlice = append(uniqueSlice, entry) + } + } + return uniqueSlice +} diff --git a/cmd/wg-portal/common/oauth.go b/cmd/wg-portal/common/oauth.go index 2f4a681..9b6912b 100644 --- a/cmd/wg-portal/common/oauth.go +++ b/cmd/wg-portal/common/oauth.go @@ -38,14 +38,16 @@ Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error) ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) + RegistrationEnabled() bool } type plainOauthAuthenticator struct { - name string - cfg *oauth2.Config - userInfoEndpoint string - client *http.Client - userInfoMapping OauthFields + name string + cfg *oauth2.Config + userInfoEndpoint string + client *http.Client + userInfoMapping OauthFields + registrationEnabled bool } func NewPlainOauthAuthenticator(_ context.Context, callbackUrl string, cfg *OAuthProvider) (*plainOauthAuthenticator, error) { @@ -68,10 +70,15 @@ } authenticator.userInfoEndpoint = cfg.UserInfoURL authenticator.userInfoMapping = getOauthFieldMapping(cfg.FieldMap) + authenticator.registrationEnabled = cfg.RegistrationEnabled return authenticator, nil } +func (p plainOauthAuthenticator) RegistrationEnabled() bool { + return p.registrationEnabled +} + func (p plainOauthAuthenticator) GetType() AuthenticatorType { return AuthenticatorTypeOAuth } @@ -127,11 +134,12 @@ } type oidcAuthenticator struct { - name string - provider *oidc.Provider - verifier *oidc.IDTokenVerifier - cfg *oauth2.Config - userInfoMapping OauthFields + name string + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + cfg *oauth2.Config + userInfoMapping OauthFields + registrationEnabled bool } func NewOidcAuthenticator(ctx context.Context, callbackUrl string, cfg *OpenIDConnectProvider) (*oidcAuthenticator, error) { @@ -157,10 +165,15 @@ Scopes: scopes, } authenticator.userInfoMapping = getOauthFieldMapping(cfg.FieldMap) + authenticator.registrationEnabled = cfg.RegistrationEnabled return authenticator, nil } +func (o oidcAuthenticator) RegistrationEnabled() bool { + return o.registrationEnabled +} + func (o oidcAuthenticator) GetType() AuthenticatorType { return AuthenticatorTypeOidc } @@ -211,28 +224,35 @@ func getOauthFieldMapping(f OauthFields) OauthFields { defaultMap := OauthFields{ - UserIdentifier: "sub", - Email: "email", - Firstname: "given_name", - Lastname: "family_name", - Phone: "phone", - Department: "department", - IsAdmin: "admin_flag", + BaseFields: BaseFields{ + UserIdentifier: "sub", + Email: "email", + Firstname: "given_name", + Lastname: "family_name", + Phone: "phone", + Department: "department", + }, + IsAdmin: "admin_flag", } - switch { - case f.UserIdentifier != "": + if f.UserIdentifier != "" { defaultMap.UserIdentifier = f.UserIdentifier - case f.Email != "": + } + if f.Email != "" { defaultMap.Email = f.Email - case f.Firstname != "": + } + if f.Firstname != "" { defaultMap.Firstname = f.Firstname - case f.Lastname != "": + } + if f.Lastname != "" { defaultMap.Lastname = f.Lastname - case f.Phone != "": + } + if f.Phone != "" { defaultMap.Phone = f.Phone - case f.Department != "": + } + if f.Department != "" { defaultMap.Department = f.Department - case f.IsAdmin != "": + } + if f.IsAdmin != "" { defaultMap.IsAdmin = f.IsAdmin } diff --git a/cmd/wg-portal/ui/handler.go b/cmd/wg-portal/ui/handler.go index 5f300ef..8d8e0ab 100644 --- a/cmd/wg-portal/ui/handler.go +++ b/cmd/wg-portal/ui/handler.go @@ -20,6 +20,7 @@ session SessionStore backend portal.Backend oauthAuthenticators map[string]common.Authenticator + ldapAuthenticators map[string]common.LdapAuthenticator } func NewHandler(config *common.Config, backend portal.Backend) (*handler, error) { @@ -28,6 +29,7 @@ backend: backend, session: GinSessionStore{sessionIdentifier: "wgPortalSession"}, oauthAuthenticators: make(map[string]common.Authenticator), + ldapAuthenticators: make(map[string]common.LdapAuthenticator), } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -80,6 +82,20 @@ } h.oauthAuthenticators[providerId] = authenticator } + for i := range h.config.Auth.Ldap { + providerCfg := &h.config.Auth.Ldap[i] + providerId := strings.ToLower(providerCfg.URL) + + if _, exists := h.ldapAuthenticators[providerId]; exists { + return errors.Errorf("auth provider with name %s is already registerd", providerId) + } + + authenticator, err := common.NewLdapAuthenticator(ctx, providerCfg) + if err != nil { + return errors.WithMessagef(err, "failed to setup ldap authentication provider %s", providerId) + } + h.ldapAuthenticators[providerId] = authenticator + } return nil } diff --git a/cmd/wg-portal/ui/pages_core.go b/cmd/wg-portal/ui/pages_core.go index da80272..9cd5e0b 100644 --- a/cmd/wg-portal/ui/pages_core.go +++ b/cmd/wg-portal/ui/pages_core.go @@ -1,6 +1,7 @@ package ui import ( + "context" "crypto/rand" "encoding/base64" "html/template" @@ -242,15 +243,70 @@ func (h *handler) passwordAuthentication(identifier persistence.UserIdentifier, password string) (*persistence.User, error) { user, err := h.backend.GetUser(identifier) - if err != nil { - return nil, errors.WithMessage(err, "user not found") + userInDatabase := false + if err == nil { + userInDatabase = true + } else { + // search user in ldap if registration is enabled + for _, authenticator := range h.ldapAuthenticators { + if !authenticator.RegistrationEnabled() { + continue + } + rawUserInfo, err := authenticator.GetUserInfo(context.Background(), identifier) + if err != nil { + continue + } + userInfo, err := authenticator.ParseUserInfo(rawUserInfo) + if err != nil { + continue + } + + user = &persistence.User{ + Identifier: userInfo.Identifier, + Email: userInfo.Email, + Source: persistence.UserSourceLdap, + IsAdmin: false, + Firstname: userInfo.Firstname, + Lastname: userInfo.Lastname, + Phone: userInfo.Phone, + Department: userInfo.Department, + // TODO: also store pw for registered user? + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + break + } } - err = h.backend.PlaintextAuthentication(identifier, password) + if user == nil { + return nil, errors.New("user not found") + } + + switch user.Source { + case persistence.UserSourceDatabase: + err = h.backend.PlaintextAuthentication(identifier, password) + case persistence.UserSourceLdap: + for _, authenticator := range h.ldapAuthenticators { + err = authenticator.PlaintextAuthentication(identifier, password) + if err == nil { + break // auth succeeded + } + } + default: + err = errors.New("no authentication backend available") + } + if err != nil { return nil, errors.WithMessage(err, "failed to authenticate") } + if !userInDatabase { + if err := h.backend.CreateUser(user); err != nil { + return nil, errors.WithMessage(err, "failed to create new ldap user") + } + } + return user, nil } diff --git a/go.mod b/go.mod index 7c4e63a..e200516 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ github.com/coreos/go-oidc/v3 v3.1.0 github.com/gin-contrib/sessions v0.0.3 github.com/gin-gonic/gin v1.7.4 + github.com/go-ldap/ldap/v3 v3.4.1 github.com/kr/text v0.2.0 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pkg/errors v0.9.1 @@ -21,7 +22,7 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210506160403-92e472f520a5 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b gorm.io/driver/mysql v1.1.2 gorm.io/driver/postgres v1.1.2 gorm.io/driver/sqlite v1.1.6