diff --git a/config.template.yml b/config.template.yml index 3ea6bdf85..c1efb2d4e 100644 --- a/config.template.yml +++ b/config.template.yml @@ -54,9 +54,10 @@ authentication_backend: # than one instance and therefore is recommended for # production. ldap: - # The url of the ldap server + # The url to the ldap server. Scheme can be ldap:// or ldaps:// url: ldap://127.0.0.1 - + # Skip verifying the server certificate (to allow self-signed certificate). + skip_verify: false # The base dn for every entries base_dn: dc=example,dc=com diff --git a/example/compose/ldap/docker-compose.yml b/example/compose/ldap/docker-compose.yml index 0780ac931..70648aebc 100644 --- a/example/compose/ldap/docker-compose.yml +++ b/example/compose/ldap/docker-compose.yml @@ -1,20 +1,20 @@ -version: '3' +version: "3" services: openldap: - image: clems4ever/openldap + image: osixia/openldap:1.3.0 + hostname: ldap.example.com environment: - - SLAPD_ORGANISATION=MyCompany - - SLAPD_DOMAIN=example.com - - SLAPD_PASSWORD=password - - SLAPD_CONFIG_PASSWORD=password - - SLAPD_ADDITIONAL_MODULES=memberof - - SLAPD_ADDITIONAL_SCHEMAS=openldap - - SLAPD_FORCE_RECONFIGURE=true + - LDAP_ORGANISATION=MyCompany + - LDAP_DOMAIN=example.com + - LDAP_ADMIN_PASSWORD=password + - LDAP_CONFIG_PASSWORD=password + - LDAP_ADDITIONAL_MODULES=memberof + - LDAP_ADDITIONAL_SCHEMAS=openldap + - LDAP_FORCE_RECONFIGURE=true + - LDAP_TLS_VERIFY_CLIENT=try volumes: - - ./example/compose/ldap/base.ldif:/etc/ldap.dist/prepopulate/base.ldif - - ./example/compose/ldap/access.rules:/etc/ldap.dist/prepopulate/access.rules - ports: - - "389:389" + - ./example/compose/ldap/ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom + command: + - --copy-service networks: - authelianet - diff --git a/example/compose/ldap/access.rules b/example/compose/ldap/ldif/access.rules similarity index 100% rename from example/compose/ldap/access.rules rename to example/compose/ldap/ldif/access.rules diff --git a/example/compose/ldap/base.ldif b/example/compose/ldap/ldif/base.ldif similarity index 100% rename from example/compose/ldap/base.ldif rename to example/compose/ldap/ldif/base.ldif diff --git a/example/kube/authelia/configs/configuration.yml b/example/kube/authelia/configs/configuration.yml index 6fc3afd84..7635cc1ed 100644 --- a/example/kube/authelia/configs/configuration.yml +++ b/example/kube/authelia/configs/configuration.yml @@ -10,7 +10,8 @@ default_redirection_url: https://home.example.com:8080 authentication_backend: ldap: - url: ldap-service:389 + url: ldaps://ldap-service + skip_verify: true base_dn: dc=example,dc=com additional_users_dn: ou=users users_filter: (cn={0}) diff --git a/example/kube/ldap/deployment.yml b/example/kube/ldap/deployment.yml index 792c87745..584135024 100644 --- a/example/kube/ldap/deployment.yml +++ b/example/kube/ldap/deployment.yml @@ -18,27 +18,35 @@ spec: spec: containers: - name: ldap - image: clems4ever/authelia-test-ldap + image: osixia/openldap:1.3.0 ports: - containerPort: 389 + - containerPort: 636 + args: ["--copy-service", "--loglevel", "debug"] env: - - name: SLAPD_ORGANISATION + - name: LDAP_ORGANISATION value: MyCompany - - name: SLAPD_DOMAIN + - name: LDAP_DOMAIN value: example.com - - name: SLAPD_PASSWORD + - name: LDAP_ADMIN_PASSWORD value: password - - name: SLAPD_CONFIG_PASSWORD + - name: LDAP_CONFIG_PASSWORD value: password - - name: SLAPD_ADDITIONAL_MODULES + - name: LDAP_ADDITIONAL_MODULES value: memberof - - name: SLAPD_ADDITIONAL_SCHEMAS + - name: LDAP_ADDITIONAL_SCHEMAS value: openldap - - name: SLAPD_FORCE_RECONFIGURE + - name: LDAP_FORCE_RECONFIGURE value: "true" + - name: LDAP_TLS_VERIFY_CLIENT + value: try volumeMounts: - name: config-volume - mountPath: /etc/ldap.dist/prepopulate + mountPath: /container/service/slapd/assets/config/bootstrap/ldif/custom/base.ldif + subPath: base.ldif + - name: config-volume + mountPath: /container/service/slapd/assets/config/bootstrap/ldif/custom/access.rules + subPath: access.rules volumes: - name: config-volume configMap: diff --git a/example/kube/ldap/service.yml b/example/kube/ldap/service.yml index 09f599258..5e10446b8 100644 --- a/example/kube/ldap/service.yml +++ b/example/kube/ldap/service.yml @@ -9,4 +9,4 @@ spec: app: ldap ports: - protocol: TCP - port: 389 + port: 636 diff --git a/go.sum b/go.sum index 735b5ac66..d57c68588 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.41.0 h1:NFvqUTDnSNYPX5oReekmB+D+90jrJIcVImxQ3qrBVgM= cloud.google.com/go v0.41.0/go.mod h1:OauMR7DV8fzvZIl2qg6rkaIhD/vmgk4iwEw/h6ercmg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= @@ -76,8 +77,10 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= @@ -179,6 +182,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1: go.mongodb.org/mongo-driver v1.1.3 h1:++7u8r9adKhGR+I79NfEtYrk2ktjenErXM99PSufIoI= go.mongodb.org/mongo-driver v1.1.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -210,6 +214,7 @@ golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -250,6 +255,7 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190624190245-7f2218787638 h1:uIfBkD8gLczr4XDgYpt/qJYds2YJwZRNw4zs7wSnNhk= golang.org/x/tools v0.0.0-20190624190245-7f2218787638/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0 h1:9sdfJOzWlkqPltHAuzT2Cp+yrBeY1KRVYgms8soxMwM= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -263,9 +269,11 @@ google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190626174449-989357319d63 h1:UsSJe9fhWNSz6emfIGPpH5DF23t7ALo2Pf3sC+/hsdg= google.golang.org/genproto v0.0.0-20190626174449-989357319d63/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1 h1:j6XxA85m/6txkUCHvzlV5f+HBNl/1r5cZ2A/3IEFOO8= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= diff --git a/internal/authentication/ldap_connection_factory.go b/internal/authentication/ldap_connection_factory.go new file mode 100644 index 000000000..b6c6b2f48 --- /dev/null +++ b/internal/authentication/ldap_connection_factory.go @@ -0,0 +1,78 @@ +package authentication + +import ( + "crypto/tls" + + "gopkg.in/ldap.v3" +) + +// ********************* CONNECTION ********************* + +// LDAPConnection interface representing a connection to the ldap. +type LDAPConnection interface { + Bind(username, password string) error + Close() + + Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) + Modify(modifyRequest *ldap.ModifyRequest) error +} + +// LDAPConnectionImpl the production implementation of an ldap connection +type LDAPConnectionImpl struct { + conn *ldap.Conn +} + +// NewLDAPConnectionImpl create a new ldap connection +func NewLDAPConnectionImpl(conn *ldap.Conn) *LDAPConnectionImpl { + return &LDAPConnectionImpl{conn} +} + +func (lc *LDAPConnectionImpl) Bind(username, password string) error { + return lc.conn.Bind(username, password) +} + +func (lc *LDAPConnectionImpl) Close() { + lc.conn.Close() +} + +func (lc *LDAPConnectionImpl) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { + return lc.conn.Search(searchRequest) +} + +func (lc *LDAPConnectionImpl) Modify(modifyRequest *ldap.ModifyRequest) error { + return lc.conn.Modify(modifyRequest) +} + +// ********************* FACTORY *********************** + +// LDAPConnectionFactory an interface of factory of ldap connections +type LDAPConnectionFactory interface { + DialTLS(network, addr string, config *tls.Config) (LDAPConnection, error) + Dial(network, addr string) (LDAPConnection, error) +} + +// LDAPConnectionFactoryImpl the production implementation of an ldap connection factory. +type LDAPConnectionFactoryImpl struct{} + +// NewLDAPConnectionFactoryImpl create a concrete ldap connection factory +func NewLDAPConnectionFactoryImpl() *LDAPConnectionFactoryImpl { + return &LDAPConnectionFactoryImpl{} +} + +// DialTLS contact ldap server over TLS. +func (lcf *LDAPConnectionFactoryImpl) DialTLS(network, addr string, config *tls.Config) (LDAPConnection, error) { + conn, err := ldap.DialTLS(network, addr, config) + if err != nil { + return nil, err + } + return NewLDAPConnectionImpl(conn), nil +} + +// Dial contact ldap server over raw tcp. +func (lcf *LDAPConnectionFactoryImpl) Dial(network, addr string) (LDAPConnection, error) { + conn, err := ldap.Dial(network, addr) + if err != nil { + return nil, err + } + return NewLDAPConnectionImpl(conn), nil +} diff --git a/internal/authentication/ldap_connection_factory_mock.go b/internal/authentication/ldap_connection_factory_mock.go new file mode 100644 index 000000000..f40983fb6 --- /dev/null +++ b/internal/authentication/ldap_connection_factory_mock.go @@ -0,0 +1,143 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/authentication/ldap_connection_factory.go + +// Package authentication is a generated GoMock package. +package authentication + +import ( + tls "crypto/tls" + gomock "github.com/golang/mock/gomock" + ldap_v3 "gopkg.in/ldap.v3" + reflect "reflect" +) + +// MockLDAPConnection is a mock of LDAPConnection interface +type MockLDAPConnection struct { + ctrl *gomock.Controller + recorder *MockLDAPConnectionMockRecorder +} + +// MockLDAPConnectionMockRecorder is the mock recorder for MockLDAPConnection +type MockLDAPConnectionMockRecorder struct { + mock *MockLDAPConnection +} + +// NewMockLDAPConnection creates a new mock instance +func NewMockLDAPConnection(ctrl *gomock.Controller) *MockLDAPConnection { + mock := &MockLDAPConnection{ctrl: ctrl} + mock.recorder = &MockLDAPConnectionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockLDAPConnection) EXPECT() *MockLDAPConnectionMockRecorder { + return m.recorder +} + +// Bind mocks base method +func (m *MockLDAPConnection) Bind(username, password string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Bind", username, password) + ret0, _ := ret[0].(error) + return ret0 +} + +// Bind indicates an expected call of Bind +func (mr *MockLDAPConnectionMockRecorder) Bind(username, password interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Bind", reflect.TypeOf((*MockLDAPConnection)(nil).Bind), username, password) +} + +// Close mocks base method +func (m *MockLDAPConnection) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close +func (mr *MockLDAPConnectionMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockLDAPConnection)(nil).Close)) +} + +// Search mocks base method +func (m *MockLDAPConnection) Search(searchRequest *ldap_v3.SearchRequest) (*ldap_v3.SearchResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Search", searchRequest) + ret0, _ := ret[0].(*ldap_v3.SearchResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Search indicates an expected call of Search +func (mr *MockLDAPConnectionMockRecorder) Search(searchRequest interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*MockLDAPConnection)(nil).Search), searchRequest) +} + +// Modify mocks base method +func (m *MockLDAPConnection) Modify(modifyRequest *ldap_v3.ModifyRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Modify", modifyRequest) + ret0, _ := ret[0].(error) + return ret0 +} + +// Modify indicates an expected call of Modify +func (mr *MockLDAPConnectionMockRecorder) Modify(modifyRequest interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Modify", reflect.TypeOf((*MockLDAPConnection)(nil).Modify), modifyRequest) +} + +// MockLDAPConnectionFactory is a mock of LDAPConnectionFactory interface +type MockLDAPConnectionFactory struct { + ctrl *gomock.Controller + recorder *MockLDAPConnectionFactoryMockRecorder +} + +// MockLDAPConnectionFactoryMockRecorder is the mock recorder for MockLDAPConnectionFactory +type MockLDAPConnectionFactoryMockRecorder struct { + mock *MockLDAPConnectionFactory +} + +// NewMockLDAPConnectionFactory creates a new mock instance +func NewMockLDAPConnectionFactory(ctrl *gomock.Controller) *MockLDAPConnectionFactory { + mock := &MockLDAPConnectionFactory{ctrl: ctrl} + mock.recorder = &MockLDAPConnectionFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockLDAPConnectionFactory) EXPECT() *MockLDAPConnectionFactoryMockRecorder { + return m.recorder +} + +// DialTLS mocks base method +func (m *MockLDAPConnectionFactory) DialTLS(network, addr string, config *tls.Config) (LDAPConnection, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DialTLS", network, addr, config) + ret0, _ := ret[0].(LDAPConnection) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DialTLS indicates an expected call of DialTLS +func (mr *MockLDAPConnectionFactoryMockRecorder) DialTLS(network, addr, config interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DialTLS", reflect.TypeOf((*MockLDAPConnectionFactory)(nil).DialTLS), network, addr, config) +} + +// Dial mocks base method +func (m *MockLDAPConnectionFactory) Dial(network, addr string) (LDAPConnection, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Dial", network, addr) + ret0, _ := ret[0].(LDAPConnection) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Dial indicates an expected call of Dial +func (mr *MockLDAPConnectionFactoryMockRecorder) Dial(network, addr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dial", reflect.TypeOf((*MockLDAPConnectionFactory)(nil).Dial), network, addr) +} diff --git a/internal/authentication/ldap_user_provider.go b/internal/authentication/ldap_user_provider.go index d02d525c9..803ae0083 100644 --- a/internal/authentication/ldap_user_provider.go +++ b/internal/authentication/ldap_user_provider.go @@ -1,35 +1,70 @@ package authentication import ( + "crypto/tls" "fmt" + "net/url" "strings" "github.com/clems4ever/authelia/internal/configuration/schema" + "github.com/clems4ever/authelia/internal/logging" "gopkg.in/ldap.v3" ) // LDAPUserProvider is a provider using a LDAP or AD as a user database. type LDAPUserProvider struct { configuration schema.LDAPAuthenticationBackendConfiguration -} -func (p *LDAPUserProvider) connect(userDN string, password string) (*ldap.Conn, error) { - conn, err := ldap.Dial("tcp", p.configuration.URL) - if err != nil { - return nil, err - } - - err = conn.Bind(userDN, password) - - if err != nil { - return nil, err - } - return conn, nil + connectionFactory LDAPConnectionFactory } // NewLDAPUserProvider creates a new instance of LDAPUserProvider. func NewLDAPUserProvider(configuration schema.LDAPAuthenticationBackendConfiguration) *LDAPUserProvider { - return &LDAPUserProvider{configuration} + return &LDAPUserProvider{ + configuration: configuration, + connectionFactory: NewLDAPConnectionFactoryImpl(), + } +} + +func NewLDAPUserProviderWithFactory(configuration schema.LDAPAuthenticationBackendConfiguration, + connectionFactory LDAPConnectionFactory) *LDAPUserProvider { + return &LDAPUserProvider{ + configuration: configuration, + connectionFactory: connectionFactory, + } +} + +func (p *LDAPUserProvider) connect(userDN string, password string) (LDAPConnection, error) { + var newConnection LDAPConnection + + url, err := url.Parse(p.configuration.URL) + + if err != nil { + return nil, fmt.Errorf("Unable to parse URL to LDAP: %s", url) + } + + if url.Scheme == "ldaps" { + logging.Logger().Debug("LDAP client starts a TLS session") + conn, err := p.connectionFactory.DialTLS("tcp", url.Host, &tls.Config{ + InsecureSkipVerify: p.configuration.SkipVerify, + }) + if err != nil { + return nil, err + } + newConnection = conn + } else { + logging.Logger().Debug("LDAP client starts a session over raw TCP") + conn, err := p.connectionFactory.Dial("tcp", url.Host) + if err != nil { + return nil, err + } + newConnection = conn + } + + if err := newConnection.Bind(userDN, password); err != nil { + return nil, err + } + return newConnection, nil } // CheckUserPassword checks if provided password matches for the given user. @@ -54,7 +89,7 @@ func (p *LDAPUserProvider) CheckUserPassword(username string, password string) ( return true, nil } -func (p *LDAPUserProvider) getUserAttribute(conn *ldap.Conn, username string, attribute string) ([]string, error) { +func (p *LDAPUserProvider) getUserAttribute(conn LDAPConnection, username string, attribute string) ([]string, error) { client, err := p.connect(p.configuration.User, p.configuration.Password) if err != nil { return nil, err @@ -86,7 +121,7 @@ func (p *LDAPUserProvider) getUserAttribute(conn *ldap.Conn, username string, at return sr.Entries[0].Attributes[0].Values, nil } -func (p *LDAPUserProvider) getUserDN(conn *ldap.Conn, username string) (string, error) { +func (p *LDAPUserProvider) getUserDN(conn LDAPConnection, username string) (string, error) { values, err := p.getUserAttribute(conn, username, "dn") if err != nil { @@ -100,7 +135,7 @@ func (p *LDAPUserProvider) getUserDN(conn *ldap.Conn, username string) (string, return values[0], nil } -func (p *LDAPUserProvider) getUserUID(conn *ldap.Conn, username string) (string, error) { +func (p *LDAPUserProvider) getUserUID(conn LDAPConnection, username string) (string, error) { values, err := p.getUserAttribute(conn, username, "uid") if err != nil { @@ -114,7 +149,7 @@ func (p *LDAPUserProvider) getUserUID(conn *ldap.Conn, username string) (string, return values[0], nil } -func (p *LDAPUserProvider) createGroupsFilter(conn *ldap.Conn, username string) (string, error) { +func (p *LDAPUserProvider) createGroupsFilter(conn LDAPConnection, username string) (string, error) { if strings.Index(p.configuration.GroupsFilter, "{0}") >= 0 { return strings.Replace(p.configuration.GroupsFilter, "{0}", username, -1), nil } else if strings.Index(p.configuration.GroupsFilter, "{dn}") >= 0 { diff --git a/internal/authentication/ldap_user_provider_test.go b/internal/authentication/ldap_user_provider_test.go new file mode 100644 index 000000000..1e666170b --- /dev/null +++ b/internal/authentication/ldap_user_provider_test.go @@ -0,0 +1,57 @@ +package authentication + +import ( + "testing" + + "github.com/clems4ever/authelia/internal/configuration/schema" + gomock "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestShouldCreateRawConnectionWhenSchemeIsLDAP(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldap := NewLDAPUserProviderWithFactory(schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldap://127.0.0.1:389", + }, mockFactory) + + mockFactory.EXPECT(). + Dial(gomock.Eq("tcp"), gomock.Eq("127.0.0.1:389")). + Return(mockConn, nil) + + mockConn.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + _, err := ldap.connect("cn=admin,dc=example,dc=com", "password") + + require.NoError(t, err) +} + +func TestShouldCreateTLSConnectionWhenSchemeIsLDAPS(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockFactory := NewMockLDAPConnectionFactory(ctrl) + mockConn := NewMockLDAPConnection(ctrl) + + ldap := NewLDAPUserProviderWithFactory(schema.LDAPAuthenticationBackendConfiguration{ + URL: "ldaps://127.0.0.1:389", + }, mockFactory) + + mockFactory.EXPECT(). + DialTLS(gomock.Eq("tcp"), gomock.Eq("127.0.0.1:389"), gomock.Any()). + Return(mockConn, nil) + + mockConn.EXPECT(). + Bind(gomock.Eq("cn=admin,dc=example,dc=com"), gomock.Eq("password")). + Return(nil) + + _, err := ldap.connect("cn=admin,dc=example,dc=com", "password") + + require.NoError(t, err) +} diff --git a/internal/configuration/schema/authentication.go b/internal/configuration/schema/authentication.go index d9ee8dfed..84a2b69c0 100644 --- a/internal/configuration/schema/authentication.go +++ b/internal/configuration/schema/authentication.go @@ -3,6 +3,7 @@ package schema // LDAPAuthenticationBackendConfiguration represents the configuration related to LDAP server. type LDAPAuthenticationBackendConfiguration struct { URL string `yaml:"url"` + SkipVerify bool `yaml:"skip_verify"` BaseDN string `yaml:"base_dn"` AdditionalUsersDN string `yaml:"additional_users_dn"` UsersFilter string `yaml:"users_filter"` diff --git a/internal/configuration/validator/authentication.go b/internal/configuration/validator/authentication.go index 688d7df06..8b8969e64 100644 --- a/internal/configuration/validator/authentication.go +++ b/internal/configuration/validator/authentication.go @@ -2,12 +2,14 @@ package validator import ( "errors" - "strings" + "fmt" + "net/url" "github.com/clems4ever/authelia/internal/configuration/schema" ) var ldapProtocolPrefix = "ldap://" +var ldapsProtocolPrefix = "ldaps://" func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationBackendConfiguration, validator *schema.StructValidator) { if configuration.Path == "" { @@ -15,19 +17,30 @@ func validateFileAuthenticationBackend(configuration *schema.FileAuthenticationB } } -func validateLdapURL(url string, validator *schema.StructValidator) string { - if strings.HasPrefix(url, ldapProtocolPrefix) { - url = url[len(ldapProtocolPrefix):] +func validateLdapURL(ldapURL string, validator *schema.StructValidator) string { + u, err := url.Parse(ldapURL) + + if err != nil { + validator.Push(errors.New("Unable to parse URL to ldap server. The scheme is probably missing: ldap:// or ldaps://")) + return "" } - portColons := strings.Index(url, ":") - - // if no port is provided, we provide the default LDAP port - // TODO(c.michaud): support LDAP over TLS. - if portColons == -1 { - url = url + ":389" + if !(u.Scheme == "ldap" || u.Scheme == "ldaps") { + validator.Push(errors.New("Unknown scheme for ldap url, should be ldap:// or ldaps://")) + return "" } - return url + + if u.Scheme == "ldap" && u.Port() == "" { + u.Host += ":389" + } else if u.Scheme == "ldaps" && u.Port() == "" { + u.Host += ":636" + } + + if !u.IsAbs() { + validator.Push(fmt.Errorf("URL to LDAP %s is still not absolute, it should be something like ldap://127.0.0.1:389", u.String())) + } + + return u.String() } func validateLdapAuthenticationBackend(configuration *schema.LDAPAuthenticationBackendConfiguration, validator *schema.StructValidator) { diff --git a/internal/configuration/validator/authentication_test.go b/internal/configuration/validator/authentication_test.go index 381d2a1c0..0b9b40b65 100644 --- a/internal/configuration/validator/authentication_test.go +++ b/internal/configuration/validator/authentication_test.go @@ -5,6 +5,7 @@ import ( "github.com/clems4ever/authelia/internal/configuration/schema" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -119,6 +120,24 @@ func (suite *LdapAuthenticationBackendSuite) TestShouldSetDefaultMailAttribute() assert.Equal(suite.T(), "mail", suite.configuration.Ldap.MailAttribute) } +func (suite *LdapAuthenticationBackendSuite) TestShouldAdaptLDAPURL() { + assert.Equal(suite.T(), "", validateLdapURL("127.0.0.1", suite.validator)) + require.Len(suite.T(), suite.validator.Errors(), 1) + assert.EqualError(suite.T(), suite.validator.Errors()[0], "Unknown scheme for ldap url, should be ldap:// or ldaps://") + + assert.Equal(suite.T(), "", validateLdapURL("127.0.0.1:636", suite.validator)) + require.Len(suite.T(), suite.validator.Errors(), 2) + assert.EqualError(suite.T(), suite.validator.Errors()[1], "Unable to parse URL to ldap server. The scheme is probably missing: ldap:// or ldaps://") + + assert.Equal(suite.T(), "ldap://127.0.0.1:389", validateLdapURL("ldap://127.0.0.1", suite.validator)) + assert.Equal(suite.T(), "ldap://127.0.0.1:390", validateLdapURL("ldap://127.0.0.1:390", suite.validator)) + assert.Equal(suite.T(), "ldap://127.0.0.1:389/abc", validateLdapURL("ldap://127.0.0.1/abc", suite.validator)) + assert.Equal(suite.T(), "ldap://127.0.0.1:389/abc?test=abc&x=y", validateLdapURL("ldap://127.0.0.1/abc?test=abc&x=y", suite.validator)) + + assert.Equal(suite.T(), "ldaps://127.0.0.1:390", validateLdapURL("ldaps://127.0.0.1:390", suite.validator)) + assert.Equal(suite.T(), "ldaps://127.0.0.1:636", validateLdapURL("ldaps://127.0.0.1", suite.validator)) +} + func TestLdapAuthenticationBackend(t *testing.T) { suite.Run(t, new(LdapAuthenticationBackendSuite)) } diff --git a/internal/suites/LDAP/configuration.yml b/internal/suites/LDAP/configuration.yml index 2232929de..8d11fb07c 100644 --- a/internal/suites/LDAP/configuration.yml +++ b/internal/suites/LDAP/configuration.yml @@ -13,7 +13,10 @@ jwt_secret: very_important_secret authentication_backend: ldap: # The url of the ldap server - url: ldap://openldap + url: ldaps://openldap + + # Skip certificate verification (for self-signed certificates) + skip_verify: true # The base dn for every entries base_dn: dc=example,dc=com diff --git a/internal/suites/suite_ldap.go b/internal/suites/suite_ldap.go index 847a2227c..f9a59eb8f 100644 --- a/internal/suites/suite_ldap.go +++ b/internal/suites/suite_ldap.go @@ -17,6 +17,7 @@ func init() { "example/compose/nginx/portal/docker-compose.yml", "example/compose/smtp/docker-compose.yml", "example/compose/ldap/docker-compose.yml", + "example/compose/ldap/docker-compose.admin.yml", }) setup := func(suitePath string) error {