diff --git a/.github/workflows/dendrite.yml b/.github/workflows/dendrite.yml index 2f615a6a4..f96e1ca2f 100644 --- a/.github/workflows/dendrite.yml +++ b/.github/workflows/dendrite.yml @@ -91,6 +91,11 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + openldap: + image: bitnami/openldap:2.5.14 + volumes: + - ${{ github.workspace }}/test/openldap:/ldifs + steps: - uses: actions/checkout@v3 - name: Setup go diff --git a/clientapi/auth/ldap_authenticator.go b/clientapi/auth/ldap_authenticator.go new file mode 100644 index 000000000..70b5a62b1 --- /dev/null +++ b/clientapi/auth/ldap_authenticator.go @@ -0,0 +1,112 @@ +package auth + +import ( + "github.com/go-ldap/ldap/v3" + "github.com/matrix-org/dendrite/clientapi/jsonerror" + "github.com/matrix-org/dendrite/setup/config" + "github.com/matrix-org/util" + "net/http" + "strings" +) + +type LdapAuthenticator struct { + config config.Ldap +} + +func NewLdapAuthenticator(config config.Ldap) *LdapAuthenticator { + return &LdapAuthenticator{ + config: config, + } +} + +func (l *LdapAuthenticator) Authenticate(username, password string) (bool, *util.JSONResponse) { + var conn *ldap.Conn + conn, err := ldap.DialURL(l.config.Uri) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("unable to connect to ldap: " + err.Error()), + } + } + defer conn.Close() + + if l.config.AdminBindEnabled { + err = conn.Bind(l.config.AdminBindDn, l.config.AdminBindPassword) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("unable to bind to ldap: " + err.Error()), + } + } + filter := strings.ReplaceAll(l.config.SearchFilter, "{username}", username) + searchRequest := ldap.NewSearchRequest( + l.config.SearchBaseDn, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 0, 0, false, filter, []string{l.config.SearchAttribute}, nil, + ) + result, err := conn.Search(searchRequest) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusInternalServerError, + JSON: jsonerror.Unknown("unable to bind to search ldap: " + err.Error()), + } + } + if len(result.Entries) > 1 { + return false, &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.BadJSON("'user' must be duplicated."), + } + } + if len(result.Entries) < 1 { + return false, &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.BadJSON("'user' not found."), + } + } + + userDN := result.Entries[0].DN + err = conn.Bind(userDN, password) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.InvalidUsername(err.Error()), + } + } + } else { + bindDn := strings.ReplaceAll(l.config.UserBindDn, "{username}", username) + err = conn.Bind(bindDn, password) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.InvalidUsername(err.Error()), + } + } + } + + isAdmin, err := l.isLdapAdmin(conn, username) + if err != nil { + return false, &util.JSONResponse{ + Code: http.StatusUnauthorized, + JSON: jsonerror.InvalidUsername(err.Error()), + } + } + return isAdmin, nil +} + +func (l *LdapAuthenticator) isLdapAdmin(conn *ldap.Conn, username string) (bool, error) { + searchRequest := ldap.NewSearchRequest( + l.config.AdminGroupDn, + ldap.ScopeWholeSubtree, ldap.DerefAlways, 0, 0, false, + strings.ReplaceAll(l.config.AdminGroupFilter, "{username}", username), + []string{l.config.AdminGroupAttribute}, + nil) + + sr, err := conn.Search(searchRequest) + if err != nil { + return false, err + } + + if len(sr.Entries) < 1 { + return false, nil + } + return true, nil +} diff --git a/clientapi/auth/ldap_authenticator_test.go b/clientapi/auth/ldap_authenticator_test.go new file mode 100644 index 000000000..3d09a5e10 --- /dev/null +++ b/clientapi/auth/ldap_authenticator_test.go @@ -0,0 +1,95 @@ +package auth + +import ( + "github.com/matrix-org/dendrite/setup/config" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestLdapAuthenticator_Authenticate_DirectBind_AdminUser(t *testing.T) { + authenticator := NewLdapAuthenticator(config.Ldap{ + Uri: "ldap://openldap:1389", + BaseDn: "dc=example,dc=org", + AdminBindEnabled: false, + UserBindDn: "cn={username},ou=users,dc=example,dc=org", + AdminGroupDn: "cn=admin,ou=groups,dc=example,dc=org", + AdminGroupFilter: "(memberUid={username})", + AdminGroupAttribute: "memberUid", + }) + + isAdmin, err := authenticator.Authenticate("user1", "password") + + assert.Nil(t, err) + assert.True(t, isAdmin) +} + +func TestLdapAuthenticator_Authenticate_DirectBind_RegularUser(t *testing.T) { + authenticator := NewLdapAuthenticator(config.Ldap{ + Uri: "ldap://openldap:1389", + BaseDn: "dc=example,dc=org", + AdminBindEnabled: false, + UserBindDn: "cn={username},ou=users,dc=example,dc=org", + AdminGroupDn: "cn=admin,ou=groups,dc=example,dc=org", + AdminGroupFilter: "(memberUid={username})", + AdminGroupAttribute: "memberUid", + }) + + isAdmin, err := authenticator.Authenticate("user2", "password") + + assert.Nil(t, err) + assert.False(t, isAdmin) +} + +func TestLdapAuthenticator_Authenticate_AdminBind(t *testing.T) { + authenticator := NewLdapAuthenticator(config.Ldap{ + Uri: "ldap://openldap:1389", + BaseDn: "dc=example,dc=org", + AdminBindEnabled: true, + AdminBindDn: "cn=admin,dc=example,dc=org", + AdminBindPassword: "password", + AdminGroupDn: "cn=admin,ou=groups,dc=example,dc=org", + AdminGroupFilter: "(memberUid={username})", + AdminGroupAttribute: "memberUid", + SearchBaseDn: "ou=users,dc=example,dc=org", + SearchFilter: "(&(objectclass=inetOrgPerson)(cn={username}))", + SearchAttribute: "cn", + }) + + isAdmin, err := authenticator.Authenticate("user1", "password") + + assert.Nil(t, err) + assert.True(t, isAdmin) +} + +func TestLdapAuthenticator_Authenticate_AdminBind_UserNotFound(t *testing.T) { + authenticator := NewLdapAuthenticator(config.Ldap{ + Uri: "ldap://openldap:1389", + BaseDn: "dc=example,dc=org", + AdminBindEnabled: true, + AdminBindDn: "cn=admin,dc=example,dc=org", + AdminBindPassword: "password", + AdminGroupDn: "cn=admin,ou=groups,dc=example,dc=org", + AdminGroupFilter: "(memberUid={username})", + AdminGroupAttribute: "memberUid", + SearchBaseDn: "ou=users,dc=example,dc=org", + SearchFilter: "(&(objectclass=inetOrgPerson)(cn={username}))", + SearchAttribute: "cn", + }) + + _, err := authenticator.Authenticate("user_not_found", "") + + assert.NotNil(t, err) +} + +func TestLdapAuthenticator_Authenticate_DirectBind_WrongPassword(t *testing.T) { + authenticator := NewLdapAuthenticator(config.Ldap{ + Uri: "ldap://openldap:1389", + BaseDn: "dc=example,dc=org", + UserBindDn: "cn={username},ou=users,dc=example,dc=org", + AdminBindEnabled: false, + }) + + _, err := authenticator.Authenticate("user2", "password_wrong") + + assert.NotNil(t, err) +} diff --git a/clientapi/auth/password.go b/clientapi/auth/password.go index 48a09e4bf..1a75ddc2b 100644 --- a/clientapi/auth/password.go +++ b/clientapi/auth/password.go @@ -17,7 +17,6 @@ package auth import ( "context" "database/sql" - "github.com/go-ldap/ldap/v3" "github.com/google/uuid" "github.com/matrix-org/gomatrixserverlib" "net/http" @@ -91,7 +90,8 @@ func (t *LoginTypePassword) Login(ctx context.Context, request *PasswordRequest) var account *api.Account if t.Config.Ldap.Enabled { - isAdmin, err := t.authenticateLdap(username, request.Password) + ldapAuthenticator := NewLdapAuthenticator(t.Config.Ldap) + isAdmin, err := ldapAuthenticator.Authenticate(username, request.Password) if err != nil { return nil, err } @@ -149,97 +149,6 @@ func (t *LoginTypePassword) authenticateDb(ctx context.Context, username string, } return res.Account, nil } -func (t *LoginTypePassword) authenticateLdap(username, password string) (bool, *util.JSONResponse) { - var conn *ldap.Conn - conn, err := ldap.DialURL(t.Config.Ldap.Uri) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusInternalServerError, - JSON: jsonerror.Unknown("unable to connect to ldap: " + err.Error()), - } - } - defer conn.Close() - - if t.Config.Ldap.AdminBindEnabled { - err = conn.Bind(t.Config.Ldap.AdminBindDn, t.Config.Ldap.AdminBindPassword) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusInternalServerError, - JSON: jsonerror.Unknown("unable to bind to ldap: " + err.Error()), - } - } - filter := strings.ReplaceAll(t.Config.Ldap.SearchFilter, "{username}", username) - searchRequest := ldap.NewSearchRequest( - t.Config.Ldap.BaseDn, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 0, 0, false, filter, []string{t.Config.Ldap.SearchAttribute}, nil, - ) - result, err := conn.Search(searchRequest) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusInternalServerError, - JSON: jsonerror.Unknown("unable to bind to search ldap: " + err.Error()), - } - } - if len(result.Entries) > 1 { - return false, &util.JSONResponse{ - Code: http.StatusUnauthorized, - JSON: jsonerror.BadJSON("'user' must be duplicated."), - } - } - if len(result.Entries) < 1 { - return false, &util.JSONResponse{ - Code: http.StatusUnauthorized, - JSON: jsonerror.BadJSON("'user' not found."), - } - } - - userDN := result.Entries[0].DN - err = conn.Bind(userDN, password) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusUnauthorized, - JSON: jsonerror.InvalidUsername(err.Error()), - } - } - } else { - bindDn := strings.ReplaceAll(t.Config.Ldap.UserBindDn, "{username}", username) - err = conn.Bind(bindDn, password) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusUnauthorized, - JSON: jsonerror.InvalidUsername(err.Error()), - } - } - } - - isAdmin, err := t.isLdapAdmin(conn, username) - if err != nil { - return false, &util.JSONResponse{ - Code: http.StatusUnauthorized, - JSON: jsonerror.InvalidUsername(err.Error()), - } - } - return isAdmin, nil -} - -func (t *LoginTypePassword) isLdapAdmin(conn *ldap.Conn, username string) (bool, error) { - searchRequest := ldap.NewSearchRequest( - t.Config.Ldap.AdminGroupDn, - ldap.ScopeWholeSubtree, ldap.DerefAlways, 0, 0, false, - strings.ReplaceAll(t.Config.Ldap.AdminGroupFilter, "{username}", username), - []string{t.Config.Ldap.AdminGroupAttribute}, - nil) - - sr, err := conn.Search(searchRequest) - if err != nil { - return false, err - } - - if len(sr.Entries) < 1 { - return false, nil - } - return true, nil -} func (t *LoginTypePassword) getOrCreateAccount(ctx context.Context, username string, domain gomatrixserverlib.ServerName, admin bool) (*api.Account, *util.JSONResponse) { var existing api.QueryAccountByLocalpartResponse diff --git a/setup/config/config_clientapi.go b/setup/config/config_clientapi.go index b1ab02ccb..f6c2973e1 100644 --- a/setup/config/config_clientapi.go +++ b/setup/config/config_clientapi.go @@ -60,11 +60,12 @@ type Ldap struct { Enabled bool `yaml:"enabled"` Uri string `yaml:"uri"` BaseDn string `yaml:"base_dn"` - SearchFilter string `yaml:"search_filter"` - SearchAttribute string `yaml:"search_attribute"` AdminBindEnabled bool `yaml:"admin_bind_enabled"` AdminBindDn string `yaml:"admin_bind_dn"` AdminBindPassword string `yaml:"admin_bind_password"` + SearchBaseDn string `yaml:"search_base_dn"` + SearchFilter string `yaml:"search_filter"` + SearchAttribute string `yaml:"search_attribute"` UserBindDn string `yaml:"user_bind_dn"` AdminGroupDn string `yaml:"admin_group_dn"` AdminGroupFilter string `yaml:"admin_group_filter"` diff --git a/test/openldap/bootstrap.ldif b/test/openldap/bootstrap.ldif new file mode 100644 index 000000000..119cb08c1 --- /dev/null +++ b/test/openldap/bootstrap.ldif @@ -0,0 +1,68 @@ +dn: dc=example,dc=org +objectClass: dcObject +objectClass: organizationalUnit + +# administrator +dn: cn=admin,dc=example,dc=org +objectClass: simpleSecurityObject +objectClass: organizationalRole +cn: admin +description: Administrator +userPassword: password + +# Subtree for Users +dn: ou=users,dc=example,dc=org +ou: Users +description: Users +objectClass: organizationalUnit +objectClass: top + +# admin user +dn: cn=user1,ou=users,dc=example,dc=org +objectClass: simpleSecurityObject +objectClass: Person +objectClass: inetOrgPerson +objectClass: posixAccount +uidNumber: 10 +gidNumber: 10 +homeDirectory: /home/user1 +uid: user1 +cn: user1 +sn: 10 +displayName: user1 +description: user1 +userPassword: user1 +mail: user1@example.com + +# regular user +dn: cn=user2,ou=users,dc=example,dc=org +objectClass: simpleSecurityObject +objectClass: Person +objectClass: inetOrgPerson +objectClass: posixAccount +uidNumber: 11 +gidNumber: 11 +homeDirectory: /home/user2 +uid: user2 +cn: user2 +sn: 11 +displayName: user2 +description: user2 +userPassword: user2 +mail: user2@example.com + +# Subtree for Groups +dn: ou=groups,dc=example,dc=org +ou: Groups +description: Groups +objectClass: organizationalUnit +objectClass: top + +# Admin group +dn: cn=admin,ou=groups,dc=example,dc=org +objectClass: posixGroup +objectClass: top +gidNumber: 1 +cn: admin +description: admin +memberUid: user1 \ No newline at end of file