From 75df94216ae98ef7ff39e701796fbb20090a2071 Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 8 Apr 2021 17:38:13 +0200 Subject: [PATCH 01/13] Add groups field on user profile (WIP) --- canaille/__init__.py | 12 +++++++----- canaille/account.py | 2 ++ canaille/forms.py | 1 + canaille/models.py | 22 ++++++++++++++++++++++ canaille/templates/profile.html | 3 +++ tests/conftest.py | 22 +++++++++++++++++++++- tests/test_groups.py | 9 +++++++++ tests/test_profile.py | 6 ++++-- 8 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 tests/test_groups.py diff --git a/canaille/__init__.py b/canaille/__init__.py index 629e4282..d55e249a 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -23,7 +23,7 @@ from flask_babel import Babel from .flaskutils import current_user from .ldaputils import LDAPObject from .oauth2utils import config_oauth -from .models import User, Token, AuthorizationCode, Client, Consent +from .models import User, Token, AuthorizationCode, Client, Consent, Group try: # pragma: no cover import sentry_sdk @@ -123,10 +123,12 @@ def setup_app(app): try: LDAPObject.root_dn = app.config["LDAP"]["ROOT_DN"] - base = app.config["LDAP"]["USER_BASE"] - if base.endswith(app.config["LDAP"]["ROOT_DN"]): - base = base[: -len(app.config["LDAP"]["ROOT_DN"]) - 1] - User.base = base + user_base = app.config["LDAP"]["USER_BASE"] + if user_base.endswith(app.config["LDAP"]["ROOT_DN"]): + user_base = user_base[: -len(app.config["LDAP"]["ROOT_DN"]) - 1] + User.base = user_base + group_base = app.config["LDAP"]["GROUP_BASE"] + Group.base = group_base app.url_map.strict_slashes = False diff --git a/canaille/account.py b/canaille/account.py index 5a5296c5..3cfb23b5 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -272,6 +272,8 @@ def profile_edit(user, username): user[attribute.name] = data else: user[attribute.name] = [data] + elif attribute.name == "groups": + user.groups = attribute.data if ( not form["password1"].data or user.set_password(form["password1"].data) diff --git a/canaille/forms.py b/canaille/forms.py index ce475188..cfb80600 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -127,6 +127,7 @@ PROFILE_FORM_FIELDS = dict( "placeholder": _("1234"), }, ), + groups=wtforms.SelectMultipleField(_("Groups"), choices=["foo", "bar"]) ) diff --git a/canaille/models.py b/canaille/models.py index 81f23bb7..76ca69dc 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -16,6 +16,7 @@ class User(LDAPObject): id = "cn" admin = False moderator = False + _groups = [] @classmethod def get(cls, login=None, dn=None, filter=None, conn=None): @@ -114,6 +115,27 @@ class User(LDAPObject): return self.cn[0] + @property + def groups(self): + return self._groups + + @groups.setter + def groups(self, value): + self._groups = value + + +class Group(LDAPObject): + id = "cn" + + @classmethod + def available_groups(cls, conn=None): + conn = conn or cls.ldap() + groups = cls.filter(objectClass=current_app.config["LDAP"].get("GROUP_CLASS"), conn=conn) + Group.attr_type_by_name(conn=conn) + attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") + return [(group[attribute][0], group.dn) for group in groups] + + class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] base = "ou=clients,ou=oauth" diff --git a/canaille/templates/profile.html b/canaille/templates/profile.html index 14f8e599..2bd6414a 100644 --- a/canaille/templates/profile.html +++ b/canaille/templates/profile.html @@ -82,6 +82,9 @@ {% if "employeeNumber" in form %} {{ sui.render_field(form.employeeNumber) }} {% endif %} + {% if "groups" in form %} + {{ sui.render_field(form.groups) }} + {% endif %}

{% trans %}Account information{% endtrans %}

{% if "uid" in form %} diff --git a/tests/conftest.py b/tests/conftest.py index 9fc872a1..96e58385 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from cryptography.hazmat.backends import default_backend as crypto_default_backe from flask_webtest import TestApp from werkzeug.security import gen_salt from canaille import create_app -from canaille.models import User, Client, Token, AuthorizationCode, Consent +from canaille.models import User, Client, Token, AuthorizationCode, Consent, Group from canaille.ldaputils import LDAPObject @@ -93,6 +93,10 @@ def slapd_server(): "dn: ou=users," + slapd.suffix, "objectClass: organizationalUnit", "ou: users", + "", + "dn: ou=groups," + slapd.suffix, + "objectClass: organizationalUnit", + "ou: groups", ] ) + "\n" @@ -100,6 +104,7 @@ def slapd_server(): LDAPObject.root_dn = slapd.suffix User.base = "ou=users" + Group.base = "ou=groups" yield slapd finally: @@ -143,7 +148,11 @@ def app(slapd_server, keypair_path): "userPassword", "telephoneNumber", "employeeNumber", + "groups", ], + "GROUP_BASE": "ou=groups", + "GROUP_CLASS": "groupOfNames", + "GROUP_NAME_ATTRIBUTE": "cn", }, "JWT": { "PUBLIC_KEY": public_key_path, @@ -338,3 +347,14 @@ def cleanups(slapd_connection): yield for consent in Consent.filter(conn=slapd_connection): consent.delete(conn=slapd_connection) + +@pytest.fixture +def foo_group(user, slapd_connection): + Group.ocs_by_name(slapd_connection) + g = Group( + objectClass = ["groupOfNames"], + member=[user.dn], + cn="foo", + ) + g.save(slapd_connection) + return g \ No newline at end of file diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 00000000..ad84d148 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,9 @@ +from canaille.models import Group + +def test_no_group(app, slapd_connection): + with app.app_context(): + assert Group.available_groups(conn=slapd_connection) == [] + +def test_foo_group(app, slapd_connection, foo_group): + with app.app_context(): + assert Group.available_groups(conn=slapd_connection) == [("foo", foo_group.dn)] \ No newline at end of file diff --git a/tests/test_profile.py b/tests/test_profile.py index afe1984e..74db6a46 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,8 +2,9 @@ import mock from canaille.models import User -def test_profile(testclient, slapd_connection, logged_user): +def test_profile(testclient, slapd_connection, logged_user, foo_group): res = testclient.get("/profile/user", status=200) + assert res.form["groups"].options == [('foo', False, 'foo')] res.form["uid"] = "user" res.form["givenName"] = "given_name" @@ -11,7 +12,7 @@ def test_profile(testclient, slapd_connection, logged_user): res.form["mail"] = "email@mydomain.tld" res.form["telephoneNumber"] = "555-666-777" res.form["employeeNumber"] = 666 - + res.form["groups"] = ["foo", "bar"] res = res.form.submit(name="action", value="edit", status=200) assert "Profile updated successfuly." in res, str(res) @@ -23,6 +24,7 @@ def test_profile(testclient, slapd_connection, logged_user): assert ["email@mydomain.tld"] == logged_user.mail assert ["555-666-777"] == logged_user.telephoneNumber assert "666" == logged_user.employeeNumber + assert ["foo", "bar"] == logged_user.groups with testclient.app.app_context(): assert logged_user.check_password("correct horse battery staple") From 8d7bb821e7b6d6114b7386bb84bf6a425d0c1ea8 Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 8 Apr 2021 21:08:49 +0200 Subject: [PATCH 02/13] Groups field options are available groups --- canaille/forms.py | 5 +++-- tests/conftest.py | 13 ++++++++++++- tests/test_profile.py | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/canaille/forms.py b/canaille/forms.py index cfb80600..feb86fe5 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -4,7 +4,7 @@ from flask import current_app from flask_babel import lazy_gettext as _ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired -from .models import User +from .models import User, Group class LoginForm(FlaskForm): @@ -127,7 +127,6 @@ PROFILE_FORM_FIELDS = dict( "placeholder": _("1234"), }, ), - groups=wtforms.SelectMultipleField(_("Groups"), choices=["foo", "bar"]) ) @@ -139,4 +138,6 @@ def profile_form(field_names): for name in field_names if PROFILE_FORM_FIELDS.get(name) } + if "groups" in field_names: + fields["groups"] = wtforms.SelectMultipleField(_("Groups"), choices=[group[0] for group in Group.available_groups()]) return wtforms.form.BaseForm(fields) diff --git a/tests/conftest.py b/tests/conftest.py index 96e58385..c7936a8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -357,4 +357,15 @@ def foo_group(user, slapd_connection): cn="foo", ) g.save(slapd_connection) - return g \ No newline at end of file + return g + +@pytest.fixture +def groups(foo_group, admin, slapd_connection): + Group.ocs_by_name(slapd_connection) + bar_group = Group( + objectClass = ["groupOfNames"], + member=[admin.dn], + cn="bar", + ) + bar_group.save(slapd_connection) + return (foo_group, bar_group) \ No newline at end of file diff --git a/tests/test_profile.py b/tests/test_profile.py index 74db6a46..33270ffe 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,9 +2,9 @@ import mock from canaille.models import User -def test_profile(testclient, slapd_connection, logged_user, foo_group): +def test_profile(testclient, slapd_connection, logged_user, groups): res = testclient.get("/profile/user", status=200) - assert res.form["groups"].options == [('foo', False, 'foo')] + assert set(res.form["groups"].options) == set([("foo", False, "foo"), ("bar", False, "bar")]) res.form["uid"] = "user" res.form["givenName"] = "given_name" From 95329b3969f15b54a10c5547b16ac04c1047580f Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 6 May 2021 17:09:34 +0200 Subject: [PATCH 03/13] WIP --- canaille/account.py | 1 - canaille/forms.py | 2 +- canaille/models.py | 38 ++++++++++++++++++++++++++++++++++---- tests/conftest.py | 1 + tests/test_profile.py | 14 +++++++++----- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/canaille/account.py b/canaille/account.py index 3cfb23b5..d32fb520 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -279,7 +279,6 @@ def profile_edit(user, username): not form["password1"].data or user.set_password(form["password1"].data) ) and request.form["action"] == "edit": flash(_("Profile updated successfuly."), "success") - user.save() return render_template( diff --git a/canaille/forms.py b/canaille/forms.py index feb86fe5..1de21609 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -139,5 +139,5 @@ def profile_form(field_names): if PROFILE_FORM_FIELDS.get(name) } if "groups" in field_names: - fields["groups"] = wtforms.SelectMultipleField(_("Groups"), choices=[group[0] for group in Group.available_groups()]) + fields["groups"] = wtforms.SelectMultipleField(_("Groups"), choices=[(group[1], group[0]) for group in Group.available_groups()]) return wtforms.form.BaseForm(fields) diff --git a/canaille/models.py b/canaille/models.py index 76ca69dc..5d8c996b 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -114,14 +114,32 @@ class User(LDAPObject): def name(self): return self.cn[0] - @property def groups(self): - return self._groups + if hasattr(self.changes, "memberOf"): + return [Group.get(group_dn) for group_dn in self.changes["memberOf"]] + elif hasattr(self.attrs, "memberOf"): + return [Group.get(group_dn) for group_dn in self.attrs["memberOf"]] + return [] + # return self._groups @groups.setter - def groups(self, value): - self._groups = value + def groups(self, values): + # self._groups = values + self.changes["memberOf"] = values + #MemberOf is a Virtual Attribute. This implies You can not monitor the MemberOf attribute for changes + #https://ldapwiki.com/wiki/MemberOf + # Cet attribut virtuel est utile dans le cas des groupes dynamiques + # groupes statiques versus dynamiques https://www.vincentliefooghe.net/content/ldap-les-types-groupes + # pour les groupes statiques c'est plus simple, on peut faire une recherche simple pour connaître tous les groupes affectés à un utlisateur + # mais groupes dynamiques plus adaptés au grands groupes + + # def save(self, conn=None): + # super().save(conn=None) + # for group in self.groups: + # if not self in group.members: + # group.members = [member.dn for member in group.members] + [self.dn] + # group.save() class Group(LDAPObject): @@ -135,6 +153,18 @@ class Group(LDAPObject): attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") return [(group[attribute][0], group.dn) for group in groups] + @property + def members(self): + if hasattr(self.changes, "member"): + return [User.get(user_dn) for user_dn in self.changes["member"]] + elif hasattr(self.attrs, "member"): + return [Group.get(user_dn) for user_dn in self.attrs["member"]] + return [] + + @members.setter + def members(self, values): + self.changes["member"] = values + class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] diff --git a/tests/conftest.py b/tests/conftest.py index c7936a8e..33725bea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,6 +153,7 @@ def app(slapd_server, keypair_path): "GROUP_BASE": "ou=groups", "GROUP_CLASS": "groupOfNames", "GROUP_NAME_ATTRIBUTE": "cn", + "USER_GROUP_ATTRIBUTE": "memberOf", #USER_GROUP_SEARCH? }, "JWT": { "PUBLIC_KEY": public_key_path, diff --git a/tests/test_profile.py b/tests/test_profile.py index 33270ffe..245082b4 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,9 +2,11 @@ import mock from canaille.models import User -def test_profile(testclient, slapd_connection, logged_user, groups): +def test_profile(testclient, slapd_server, slapd_connection, logged_user, groups): res = testclient.get("/profile/user", status=200) - assert set(res.form["groups"].options) == set([("foo", False, "foo"), ("bar", False, "bar")]) + assert set(res.form["groups"].options) == set([("cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "foo"), ("cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "bar")]) + assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() + assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat() res.form["uid"] = "user" res.form["givenName"] = "given_name" @@ -12,19 +14,21 @@ def test_profile(testclient, slapd_connection, logged_user, groups): res.form["mail"] = "email@mydomain.tld" res.form["telephoneNumber"] = "555-666-777" res.form["employeeNumber"] = 666 - res.form["groups"] = ["foo", "bar"] + res.form["groups"] = ["cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", "cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org"] res = res.form.submit(name="action", value="edit", status=200) assert "Profile updated successfuly." in res, str(res) - logged_user.reload(slapd_connection) + assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() + assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() + logged_user.reload(slapd_connection) assert ["user"] == logged_user.uid assert ["given_name"] == logged_user.givenName assert ["family_name"] == logged_user.sn assert ["email@mydomain.tld"] == logged_user.mail assert ["555-666-777"] == logged_user.telephoneNumber assert "666" == logged_user.employeeNumber - assert ["foo", "bar"] == logged_user.groups + assert groups == logged_user.groups with testclient.app.app_context(): assert logged_user.check_password("correct horse battery staple") From e07eb0eb507140f5f5d1887cf69f65c46829f52a Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 6 May 2021 19:12:14 +0200 Subject: [PATCH 04/13] Save user groups (WIP) --- canaille/models.py | 42 ++++++++++++++++------------------------- demo/conf/canaille.toml | 5 +++++ tests/test_groups.py | 6 ++++-- tests/test_profile.py | 8 ++++---- 4 files changed, 29 insertions(+), 32 deletions(-) diff --git a/canaille/models.py b/canaille/models.py index 5d8c996b..7a2699b5 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -116,17 +116,17 @@ class User(LDAPObject): @property def groups(self): - if hasattr(self.changes, "memberOf"): - return [Group.get(group_dn) for group_dn in self.changes["memberOf"]] - elif hasattr(self.attrs, "memberOf"): - return [Group.get(group_dn) for group_dn in self.attrs["memberOf"]] - return [] - # return self._groups + # if hasattr(self.changes, "memberOf"): + # return [Group.get(group_dn) for group_dn in self.changes["memberOf"]] + # elif hasattr(self.attrs, "memberOf"): + # return [Group.get(group_dn) for group_dn in self.attrs["memberOf"]] + # return [] + return [Group.get(dn=group_dn) for group_dn in self._groups] @groups.setter def groups(self, values): - # self._groups = values - self.changes["memberOf"] = values + self._groups = values + # self.changes["memberOf"] = values #MemberOf is a Virtual Attribute. This implies You can not monitor the MemberOf attribute for changes #https://ldapwiki.com/wiki/MemberOf # Cet attribut virtuel est utile dans le cas des groupes dynamiques @@ -134,12 +134,12 @@ class User(LDAPObject): # pour les groupes statiques c'est plus simple, on peut faire une recherche simple pour connaître tous les groupes affectés à un utlisateur # mais groupes dynamiques plus adaptés au grands groupes - # def save(self, conn=None): - # super().save(conn=None) - # for group in self.groups: - # if not self in group.members: - # group.members = [member.dn for member in group.members] + [self.dn] - # group.save() + def save(self, conn=None): + super().save(conn=conn) + for group in self.groups: + if not self.dn in group.member: + group.member = group.member + [self.dn] + group.save() class Group(LDAPObject): @@ -153,18 +153,8 @@ class Group(LDAPObject): attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") return [(group[attribute][0], group.dn) for group in groups] - @property - def members(self): - if hasattr(self.changes, "member"): - return [User.get(user_dn) for user_dn in self.changes["member"]] - elif hasattr(self.attrs, "member"): - return [Group.get(user_dn) for user_dn in self.attrs["member"]] - return [] - - @members.setter - def members(self, values): - self.changes["member"] = values - + def get_members(self, conn=None): + return [User.get(dn=user_dn, conn=conn) for user_dn in self.member] class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] diff --git a/demo/conf/canaille.toml b/demo/conf/canaille.toml index 01868a67..57217177 100644 --- a/demo/conf/canaille.toml +++ b/demo/conf/canaille.toml @@ -79,8 +79,13 @@ FIELDS = [ "telephoneNumber", "employeeNumber", # "jpegPhoto", + "groups", ] +GROUP_BASE = "ou=groups" +GROUP_CLASS = "groupOfNames" +GROUP_NAME_ATTRIBUTE = "cn" + # The jwt configuration. You can generate a RSA keypair with: # ssh-keygen -t rsa -b 4096 -m PEM -f private.pem # openssl rsa -in private.pem -pubout -outform PEM -out public.pem diff --git a/tests/test_groups.py b/tests/test_groups.py index ad84d148..38db7147 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -4,6 +4,8 @@ def test_no_group(app, slapd_connection): with app.app_context(): assert Group.available_groups(conn=slapd_connection) == [] -def test_foo_group(app, slapd_connection, foo_group): +def test_foo_group(app, slapd_connection, user, foo_group): with app.app_context(): - assert Group.available_groups(conn=slapd_connection) == [("foo", foo_group.dn)] \ No newline at end of file + assert Group.available_groups(conn=slapd_connection) == [("foo", foo_group.dn)] + assert foo_group.get_members(conn=slapd_connection) == [user] + assert user.groups == [foo_group] diff --git a/tests/test_profile.py b/tests/test_profile.py index 245082b4..7f98a54f 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -5,8 +5,8 @@ from canaille.models import User def test_profile(testclient, slapd_server, slapd_connection, logged_user, groups): res = testclient.get("/profile/user", status=200) assert set(res.form["groups"].options) == set([("cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "foo"), ("cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "bar")]) - assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() - assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat() + assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat().stdout.decode("utf-8") + assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat().stdout.decode("utf-8") res.form["uid"] = "user" res.form["givenName"] = "given_name" @@ -18,8 +18,8 @@ def test_profile(testclient, slapd_server, slapd_connection, logged_user, groups res = res.form.submit(name="action", value="edit", status=200) assert "Profile updated successfuly." in res, str(res) - assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() - assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat() + assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat().stdout.decode("utf-8") + assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat().stdout.decode("utf-8") logged_user.reload(slapd_connection) assert ["user"] == logged_user.uid From f05e8094cb178602a5d6c2a9ec48df6f1b6437fa Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 3 Jun 2021 12:00:04 +0200 Subject: [PATCH 05/13] Set user groups --- canaille/account.py | 2 +- canaille/ldaputils.py | 6 ++++++ canaille/models.py | 44 ++++++++++++++++++++++++---------------- tests/conftest.py | 18 +++++++++++----- tests/test_groups.py | 29 +++++++++++++++++++++----- tests/test_ldap_utils.py | 7 +++++++ tests/test_profile.py | 21 +++++++++++-------- 7 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 tests/test_ldap_utils.py diff --git a/canaille/account.py b/canaille/account.py index d32fb520..06ceb3e0 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -273,7 +273,7 @@ def profile_edit(user, username): else: user[attribute.name] = [data] elif attribute.name == "groups": - user.groups = attribute.data + user.set_groups(attribute.data) if ( not form["password1"].data or user.set_password(form["password1"].data) diff --git a/canaille/ldaputils.py b/canaille/ldaputils.py index 01cdc6eb..d86e653a 100644 --- a/canaille/ldaputils.py +++ b/canaille/ldaputils.py @@ -263,3 +263,9 @@ class LDAPObject: self.changes[name] = [value] else: self.changes[name] = value + + def __eq__(self, other): + return self.may == other.may and self.must == other.must and all(getattr(self, attr) == getattr(other, attr) for attr in self.may + self.must) + + def __hash__(self): + return hash(self.dn) \ No newline at end of file diff --git a/canaille/models.py b/canaille/models.py index 7a2699b5..58c88d7b 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -48,8 +48,15 @@ class User(LDAPObject): user.moderator = True + if user: + user.load_groups(conn=conn) + return user + def load_groups(self, conn=None): + group_filter = current_app.config["LDAP"].get("GROUP_FILTER").format(user=self) + self._groups = Group.filter(filter=group_filter, conn=conn) + @classmethod def authenticate(cls, login, password, signin=False): user = User.get(login) @@ -121,26 +128,19 @@ class User(LDAPObject): # elif hasattr(self.attrs, "memberOf"): # return [Group.get(group_dn) for group_dn in self.attrs["memberOf"]] # return [] + return self._groups return [Group.get(dn=group_dn) for group_dn in self._groups] - @groups.setter - def groups(self, values): - self._groups = values - # self.changes["memberOf"] = values - #MemberOf is a Virtual Attribute. This implies You can not monitor the MemberOf attribute for changes - #https://ldapwiki.com/wiki/MemberOf - # Cet attribut virtuel est utile dans le cas des groupes dynamiques - # groupes statiques versus dynamiques https://www.vincentliefooghe.net/content/ldap-les-types-groupes - # pour les groupes statiques c'est plus simple, on peut faire une recherche simple pour connaître tous les groupes affectés à un utlisateur - # mais groupes dynamiques plus adaptés au grands groupes - - def save(self, conn=None): - super().save(conn=conn) - for group in self.groups: - if not self.dn in group.member: - group.member = group.member + [self.dn] - group.save() - + def set_groups(self, values, conn=None): + before = self._groups + after = [v if isinstance(v, Group) else Group.get(dn=v, conn=conn) for v in values] + to_add = set(after) - set(before) + to_del = set(before) - set(after) + for group in to_add: + group.add_member(self, conn=conn) + for group in to_del: + group.remove_member(self, conn=conn) + self._groups = after class Group(LDAPObject): id = "cn" @@ -156,6 +156,14 @@ class Group(LDAPObject): def get_members(self, conn=None): return [User.get(dn=user_dn, conn=conn) for user_dn in self.member] + def add_member(self, user, conn=None): + self.member = self.member + [user.dn] + self.save(conn=conn) + + def remove_member(self, user, conn=None): + self.member = [m for m in self.member if m != user.dn] + self.save(conn=conn) + class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] base = "ou=clients,ou=oauth" diff --git a/tests/conftest.py b/tests/conftest.py index 33725bea..7030208b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,7 +153,7 @@ def app(slapd_server, keypair_path): "GROUP_BASE": "ou=groups", "GROUP_CLASS": "groupOfNames", "GROUP_NAME_ATTRIBUTE": "cn", - "USER_GROUP_ATTRIBUTE": "memberOf", #USER_GROUP_SEARCH? + "GROUP_FILTER": "(member={user.dn})" }, "JWT": { "PUBLIC_KEY": public_key_path, @@ -350,7 +350,7 @@ def cleanups(slapd_connection): consent.delete(conn=slapd_connection) @pytest.fixture -def foo_group(user, slapd_connection): +def foo_group(app, user, slapd_connection): Group.ocs_by_name(slapd_connection) g = Group( objectClass = ["groupOfNames"], @@ -358,15 +358,23 @@ def foo_group(user, slapd_connection): cn="foo", ) g.save(slapd_connection) + with app.app_context(): + user.load_groups(conn=slapd_connection) return g @pytest.fixture -def groups(foo_group, admin, slapd_connection): +def bar_group(app, admin, slapd_connection): Group.ocs_by_name(slapd_connection) - bar_group = Group( + g = Group( objectClass = ["groupOfNames"], member=[admin.dn], cn="bar", ) - bar_group.save(slapd_connection) + g.save(slapd_connection) + with app.app_context(): + admin.load_groups(conn=slapd_connection) + return g + +@pytest.fixture +def groups(foo_group, bar_group, slapd_connection): return (foo_group, bar_group) \ No newline at end of file diff --git a/tests/test_groups.py b/tests/test_groups.py index 38db7147..c79b3459 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,11 +1,30 @@ -from canaille.models import Group +from canaille.models import Group, User def test_no_group(app, slapd_connection): with app.app_context(): assert Group.available_groups(conn=slapd_connection) == [] -def test_foo_group(app, slapd_connection, user, foo_group): +def test_set_groups(app, slapd_connection, user, foo_group, bar_group): with app.app_context(): - assert Group.available_groups(conn=slapd_connection) == [("foo", foo_group.dn)] - assert foo_group.get_members(conn=slapd_connection) == [user] - assert user.groups == [foo_group] + Group.attr_type_by_name(conn=slapd_connection) + a = User.attr_type_by_name(conn=slapd_connection) + + + user = User.get(dn=user.dn, conn=slapd_connection) + assert set(Group.available_groups(conn=slapd_connection)) == {("foo", foo_group.dn), ("bar", bar_group.dn)} + foo_dns = {g.dn for g in foo_group.get_members(conn=slapd_connection)} + assert user.dn in foo_dns + assert user.groups[0].dn == foo_group.dn + + user.set_groups([foo_group, bar_group], conn=slapd_connection) + bar_dns = {g.dn for g in bar_group.get_members(conn=slapd_connection)} + assert user.dn in bar_dns + + assert user.groups[1].dn == bar_group.dn + + user.set_groups([foo_group], conn=slapd_connection) + foo_dns = {g.dn for g in foo_group.get_members(conn=slapd_connection)} + bar_dns = {g.dn for g in bar_group.get_members(conn=slapd_connection)} + + assert user.dn in foo_dns + assert user.dn not in bar_dns diff --git a/tests/test_ldap_utils.py b/tests/test_ldap_utils.py new file mode 100644 index 00000000..77839eba --- /dev/null +++ b/tests/test_ldap_utils.py @@ -0,0 +1,7 @@ +from canaille.models import Group + +def test_equality(slapd_connection, foo_group, bar_group): + Group.attr_type_by_name(conn=slapd_connection) + assert foo_group != bar_group + foo_group2 = Group.get(dn=foo_group.dn, conn=slapd_connection) + assert foo_group == foo_group2 \ No newline at end of file diff --git a/tests/test_profile.py b/tests/test_profile.py index 7f98a54f..5a0968df 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,11 +2,12 @@ import mock from canaille.models import User -def test_profile(testclient, slapd_server, slapd_connection, logged_user, groups): +def test_profile(testclient, slapd_server, slapd_connection, logged_user, admin, foo_group, bar_group): res = testclient.get("/profile/user", status=200) assert set(res.form["groups"].options) == set([("cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "foo"), ("cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "bar")]) - assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat().stdout.decode("utf-8") - assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" not in slapd_server.slapcat().stdout.decode("utf-8") + assert logged_user.groups == [foo_group] + assert foo_group.member == [logged_user.dn] + assert bar_group.member == [admin.dn] res.form["uid"] = "user" res.form["givenName"] = "given_name" @@ -18,17 +19,21 @@ def test_profile(testclient, slapd_server, slapd_connection, logged_user, groups res = res.form.submit(name="action", value="edit", status=200) assert "Profile updated successfuly." in res, str(res) - assert "memberOf: cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat().stdout.decode("utf-8") - assert "memberOf: cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org" in slapd_server.slapcat().stdout.decode("utf-8") - - logged_user.reload(slapd_connection) + with testclient.app.app_context(): + logged_user = User.get(dn=logged_user.dn, conn=slapd_connection) assert ["user"] == logged_user.uid assert ["given_name"] == logged_user.givenName assert ["family_name"] == logged_user.sn assert ["email@mydomain.tld"] == logged_user.mail assert ["555-666-777"] == logged_user.telephoneNumber assert "666" == logged_user.employeeNumber - assert groups == logged_user.groups + + foo_group.reload(slapd_connection) + bar_group.reload(slapd_connection) + assert set(foo_group.member) == {logged_user.dn} + assert set(bar_group.member) == {admin.dn, logged_user.dn} + assert set(logged_user.groups) == {foo_group, bar_group} + with testclient.app.app_context(): assert logged_user.check_password("correct horse battery staple") From b6ef56ad20c44b817d514cb32b1981f34368ea42 Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 3 Jun 2021 12:28:45 +0200 Subject: [PATCH 06/13] Improve things --- canaille/account.py | 2 ++ canaille/conf/config.sample.toml | 6 ++++++ canaille/forms.py | 5 ++++- canaille/ldaputils.py | 11 +++++++++-- canaille/models.py | 20 +++++++++++--------- demo/conf/canaille.toml | 1 + tests/conftest.py | 13 ++++++++----- tests/test_groups.py | 8 ++++++-- tests/test_ldap_utils.py | 3 ++- tests/test_profile.py | 17 +++++++++++++---- 10 files changed, 62 insertions(+), 24 deletions(-) diff --git a/canaille/account.py b/canaille/account.py index 06ceb3e0..2948a7ee 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -252,6 +252,8 @@ def profile_edit(user, username): for k in fields if hasattr(user, k) } + if "groups" in fields: + data["groups"] = [g.dn for g in user.groups] form = profile_form(fields) form.process(CombinedMultiDict((request.files, request.form)) or None, data=data) form["uid"].render_kw["readonly"] = "true" diff --git a/canaille/conf/config.sample.toml b/canaille/conf/config.sample.toml index 15230c5b..b790651a 100644 --- a/canaille/conf/config.sample.toml +++ b/canaille/conf/config.sample.toml @@ -68,6 +68,11 @@ ADMIN_FILTER = "memberof=cn=admins,ou=groups,dc=mydomain,dc=tld" # USER_ADMIN_FILTER = "uid=moderator" USER_ADMIN_FILTER = "memberof=cn=moderators,ou=groups,dc=mydomain,dc=tld" +GROUP_BASE = "ou=groups" +GROUP_CLASS = "groupOfNames" +GROUP_NAME_ATTRIBUTE = "cn" +GROUP_USER_FILTER = "(member={user.dn})" + # The list of ldap fields you want to be editable by the # users. FIELDS = [ @@ -79,6 +84,7 @@ FIELDS = [ "telephoneNumber", "employeeNumber", # "jpegPhoto", + "groups", ] # The jwt configuration. You can generate a RSA keypair with: diff --git a/canaille/forms.py b/canaille/forms.py index 1de21609..14749247 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -139,5 +139,8 @@ def profile_form(field_names): if PROFILE_FORM_FIELDS.get(name) } if "groups" in field_names: - fields["groups"] = wtforms.SelectMultipleField(_("Groups"), choices=[(group[1], group[0]) for group in Group.available_groups()]) + fields["groups"] = wtforms.SelectMultipleField( + _("Groups"), + choices=[(group[1], group[0]) for group in Group.available_groups()], + ) return wtforms.form.BaseForm(fields) diff --git a/canaille/ldaputils.py b/canaille/ldaputils.py index d86e653a..05b0f304 100644 --- a/canaille/ldaputils.py +++ b/canaille/ldaputils.py @@ -265,7 +265,14 @@ class LDAPObject: self.changes[name] = value def __eq__(self, other): - return self.may == other.may and self.must == other.must and all(getattr(self, attr) == getattr(other, attr) for attr in self.may + self.must) + return ( + self.may == other.may + and self.must == other.must + and all( + getattr(self, attr) == getattr(other, attr) + for attr in self.may + self.must + ) + ) def __hash__(self): - return hash(self.dn) \ No newline at end of file + return hash(self.dn) diff --git a/canaille/models.py b/canaille/models.py index 58c88d7b..b1b85ae8 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -54,7 +54,9 @@ class User(LDAPObject): return user def load_groups(self, conn=None): - group_filter = current_app.config["LDAP"].get("GROUP_FILTER").format(user=self) + group_filter = ( + current_app.config["LDAP"].get("GROUP_USER_FILTER").format(user=self) + ) self._groups = Group.filter(filter=group_filter, conn=conn) @classmethod @@ -123,17 +125,13 @@ class User(LDAPObject): @property def groups(self): - # if hasattr(self.changes, "memberOf"): - # return [Group.get(group_dn) for group_dn in self.changes["memberOf"]] - # elif hasattr(self.attrs, "memberOf"): - # return [Group.get(group_dn) for group_dn in self.attrs["memberOf"]] - # return [] return self._groups - return [Group.get(dn=group_dn) for group_dn in self._groups] def set_groups(self, values, conn=None): before = self._groups - after = [v if isinstance(v, Group) else Group.get(dn=v, conn=conn) for v in values] + after = [ + v if isinstance(v, Group) else Group.get(dn=v, conn=conn) for v in values + ] to_add = set(after) - set(before) to_del = set(before) - set(after) for group in to_add: @@ -142,13 +140,16 @@ class User(LDAPObject): group.remove_member(self, conn=conn) self._groups = after + class Group(LDAPObject): id = "cn" @classmethod def available_groups(cls, conn=None): conn = conn or cls.ldap() - groups = cls.filter(objectClass=current_app.config["LDAP"].get("GROUP_CLASS"), conn=conn) + groups = cls.filter( + objectClass=current_app.config["LDAP"].get("GROUP_CLASS"), conn=conn + ) Group.attr_type_by_name(conn=conn) attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") return [(group[attribute][0], group.dn) for group in groups] @@ -164,6 +165,7 @@ class Group(LDAPObject): self.member = [m for m in self.member if m != user.dn] self.save(conn=conn) + class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] base = "ou=clients,ou=oauth" diff --git a/demo/conf/canaille.toml b/demo/conf/canaille.toml index 57217177..082a9642 100644 --- a/demo/conf/canaille.toml +++ b/demo/conf/canaille.toml @@ -85,6 +85,7 @@ FIELDS = [ GROUP_BASE = "ou=groups" GROUP_CLASS = "groupOfNames" GROUP_NAME_ATTRIBUTE = "cn" +GROUP_USER_FILTER = "(member={user.dn})" # The jwt configuration. You can generate a RSA keypair with: # ssh-keygen -t rsa -b 4096 -m PEM -f private.pem diff --git a/tests/conftest.py b/tests/conftest.py index 7030208b..5fcb2d1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,7 +153,7 @@ def app(slapd_server, keypair_path): "GROUP_BASE": "ou=groups", "GROUP_CLASS": "groupOfNames", "GROUP_NAME_ATTRIBUTE": "cn", - "GROUP_FILTER": "(member={user.dn})" + "GROUP_USER_FILTER": "(member={user.dn})", }, "JWT": { "PUBLIC_KEY": public_key_path, @@ -349,11 +349,12 @@ def cleanups(slapd_connection): for consent in Consent.filter(conn=slapd_connection): consent.delete(conn=slapd_connection) + @pytest.fixture def foo_group(app, user, slapd_connection): Group.ocs_by_name(slapd_connection) g = Group( - objectClass = ["groupOfNames"], + objectClass=["groupOfNames"], member=[user.dn], cn="foo", ) @@ -362,11 +363,12 @@ def foo_group(app, user, slapd_connection): user.load_groups(conn=slapd_connection) return g + @pytest.fixture def bar_group(app, admin, slapd_connection): Group.ocs_by_name(slapd_connection) g = Group( - objectClass = ["groupOfNames"], + objectClass=["groupOfNames"], member=[admin.dn], cn="bar", ) @@ -374,7 +376,8 @@ def bar_group(app, admin, slapd_connection): with app.app_context(): admin.load_groups(conn=slapd_connection) return g - + + @pytest.fixture def groups(foo_group, bar_group, slapd_connection): - return (foo_group, bar_group) \ No newline at end of file + return (foo_group, bar_group) diff --git a/tests/test_groups.py b/tests/test_groups.py index c79b3459..75ca8560 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -1,17 +1,21 @@ from canaille.models import Group, User + def test_no_group(app, slapd_connection): with app.app_context(): assert Group.available_groups(conn=slapd_connection) == [] + def test_set_groups(app, slapd_connection, user, foo_group, bar_group): with app.app_context(): Group.attr_type_by_name(conn=slapd_connection) a = User.attr_type_by_name(conn=slapd_connection) - user = User.get(dn=user.dn, conn=slapd_connection) - assert set(Group.available_groups(conn=slapd_connection)) == {("foo", foo_group.dn), ("bar", bar_group.dn)} + assert set(Group.available_groups(conn=slapd_connection)) == { + ("foo", foo_group.dn), + ("bar", bar_group.dn), + } foo_dns = {g.dn for g in foo_group.get_members(conn=slapd_connection)} assert user.dn in foo_dns assert user.groups[0].dn == foo_group.dn diff --git a/tests/test_ldap_utils.py b/tests/test_ldap_utils.py index 77839eba..f54792d6 100644 --- a/tests/test_ldap_utils.py +++ b/tests/test_ldap_utils.py @@ -1,7 +1,8 @@ from canaille.models import Group + def test_equality(slapd_connection, foo_group, bar_group): Group.attr_type_by_name(conn=slapd_connection) assert foo_group != bar_group foo_group2 = Group.get(dn=foo_group.dn, conn=slapd_connection) - assert foo_group == foo_group2 \ No newline at end of file + assert foo_group == foo_group2 diff --git a/tests/test_profile.py b/tests/test_profile.py index 5a0968df..f8b9097f 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -2,9 +2,16 @@ import mock from canaille.models import User -def test_profile(testclient, slapd_server, slapd_connection, logged_user, admin, foo_group, bar_group): +def test_profile( + testclient, slapd_server, slapd_connection, logged_user, admin, foo_group, bar_group +): res = testclient.get("/profile/user", status=200) - assert set(res.form["groups"].options) == set([("cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "foo"), ("cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "bar")]) + assert set(res.form["groups"].options) == set( + [ + ("cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", True, "foo"), + ("cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", False, "bar"), + ] + ) assert logged_user.groups == [foo_group] assert foo_group.member == [logged_user.dn] assert bar_group.member == [admin.dn] @@ -15,7 +22,10 @@ def test_profile(testclient, slapd_server, slapd_connection, logged_user, admin, res.form["mail"] = "email@mydomain.tld" res.form["telephoneNumber"] = "555-666-777" res.form["employeeNumber"] = 666 - res.form["groups"] = ["cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", "cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org"] + res.form["groups"] = [ + "cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", + "cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", + ] res = res.form.submit(name="action", value="edit", status=200) assert "Profile updated successfuly." in res, str(res) @@ -34,7 +44,6 @@ def test_profile(testclient, slapd_server, slapd_connection, logged_user, admin, assert set(bar_group.member) == {admin.dn, logged_user.dn} assert set(logged_user.groups) == {foo_group, bar_group} - with testclient.app.app_context(): assert logged_user.check_password("correct horse battery staple") From 294b86a698ee969fe01b8ab212ccdce96e521e74 Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 3 Jun 2021 14:47:19 +0200 Subject: [PATCH 07/13] Only moderators and admin can edit user groups --- canaille/account.py | 17 ++++++++++++----- canaille/forms.py | 1 + tests/test_profile.py | 31 ++++++++++++++++++++++++------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/canaille/account.py b/canaille/account.py index 2948a7ee..efcc33e2 100644 --- a/canaille/account.py +++ b/canaille/account.py @@ -239,11 +239,13 @@ def profile_edition(user, username): abort(400) -def profile_edit(user, username): - menuitem = "profile" if username == user.uid[0] else "users" +def profile_edit(editor, username): + menuitem = "profile" if username == editor.uid[0] else "users" fields = current_app.config["LDAP"]["FIELDS"] - if username != user.uid[0]: + if username != editor.uid[0]: user = User.get(username) or abort(404) + else: + user = editor data = { k: getattr(user, k)[0] @@ -257,6 +259,8 @@ def profile_edit(user, username): form = profile_form(fields) form.process(CombinedMultiDict((request.files, request.form)) or None, data=data) form["uid"].render_kw["readonly"] = "true" + if "groups" in form and not editor.admin and not editor.moderator: + form["groups"].render_kw["disabled"] = "true" if request.form: if not form.validate(): @@ -264,7 +268,10 @@ def profile_edit(user, username): else: for attribute in form: - if attribute.name in user.may + user.must: + if ( + attribute.name in user.may + user.must + and not attribute.name == "uid" + ): if isinstance(attribute.data, FileStorage): data = attribute.data.stream.read() else: @@ -274,7 +281,7 @@ def profile_edit(user, username): user[attribute.name] = data else: user[attribute.name] = [data] - elif attribute.name == "groups": + elif attribute.name == "groups" and (editor.admin or editor.moderator): user.set_groups(attribute.data) if ( diff --git a/canaille/forms.py b/canaille/forms.py index 14749247..6160aa9f 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -142,5 +142,6 @@ def profile_form(field_names): fields["groups"] = wtforms.SelectMultipleField( _("Groups"), choices=[(group[1], group[0]) for group in Group.available_groups()], + render_kw={}, ) return wtforms.form.BaseForm(fields) diff --git a/tests/test_profile.py b/tests/test_profile.py index f8b9097f..778f3daf 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -15,8 +15,10 @@ def test_profile( assert logged_user.groups == [foo_group] assert foo_group.member == [logged_user.dn] assert bar_group.member == [admin.dn] + assert res.form["groups"].attrs["disabled"] + assert res.form["uid"].attrs["readonly"] - res.form["uid"] = "user" + res.form["uid"] = "toto" res.form["givenName"] = "given_name" res.form["sn"] = "family_name" res.form["mail"] = "email@mydomain.tld" @@ -40,9 +42,9 @@ def test_profile( foo_group.reload(slapd_connection) bar_group.reload(slapd_connection) - assert set(foo_group.member) == {logged_user.dn} - assert set(bar_group.member) == {admin.dn, logged_user.dn} - assert set(logged_user.groups) == {foo_group, bar_group} + assert logged_user.groups == [foo_group] + assert foo_group.member == [logged_user.dn] + assert bar_group.member == [admin.dn] with testclient.app.app_context(): assert logged_user.check_password("correct horse battery staple") @@ -127,7 +129,7 @@ def test_admin_bad_request(testclient, logged_moderator): def test_user_creation_edition_and_deletion( - testclient, slapd_connection, logged_moderator + testclient, slapd_connection, logged_moderator, foo_group, bar_group ): # The user does not exist. res = testclient.get("/users", status=200) @@ -148,14 +150,29 @@ def test_user_creation_edition_and_deletion( # User have been created res = res.form.submit(name="action", value="edit", status=302).follow(status=200) with testclient.app.app_context(): - assert "George" == User.get("george", conn=slapd_connection).givenName[0] + george = User.get("george", conn=slapd_connection) + assert "George" == george.givenName[0] + assert george.groups == [] assert "george" in testclient.get("/users", status=200).text + assert "disabled" not in res.form["groups"].attrs + res.form["givenName"] = "Georgio" + res.form["groups"] = [ + "cn=foo,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", + "cn=bar,ou=groups,dc=slapd-test,dc=python-ldap,dc=org", + ] # User have been edited res = res.form.submit(name="action", value="edit", status=200) with testclient.app.app_context(): - assert "Georgio" == User.get("george", conn=slapd_connection).givenName[0] + george = User.get("george", conn=slapd_connection) + assert "Georgio" == george.givenName[0] + foo_group.reload(slapd_connection) + bar_group.reload(slapd_connection) + assert george.dn in set(foo_group.member) + assert george.dn in set(bar_group.member) + assert set(george.groups) == {foo_group, bar_group} + assert "george" in testclient.get("/users", status=200).text assert "george" in testclient.get("/users", status=200).text # User have been deleted. From f1ac9e140a2698337f65951b61ead9eec82d546b Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 3 Jun 2021 17:24:36 +0200 Subject: [PATCH 08/13] Add groups claim and scope --- .../oauth-authorization-server.sample.json | 2 +- .../conf/openid-configuration.sample.json | 2 +- canaille/oauth.py | 1 + canaille/oauth2utils.py | 5 ++ demo/client/__init__.py | 2 +- demo/client/templates/index.html | 5 +- demo/conf/oauth-authorization-server.json | 2 +- demo/conf/openid-configuration.json | 2 +- demo/ldif/bootstrap.ldif | 2 + tests/conftest.py | 11 +++- tests/test_authorization_code_flow.py | 8 +-- tests/test_hybrid_flow.py | 7 ++- tests/test_implicit_flow.py | 57 ++++++++++++++++++- tests/test_password_flow.py | 4 +- 14 files changed, 90 insertions(+), 20 deletions(-) diff --git a/canaille/conf/oauth-authorization-server.sample.json b/canaille/conf/oauth-authorization-server.sample.json index b365b625..7e4565c6 100644 --- a/canaille/conf/oauth-authorization-server.sample.json +++ b/canaille/conf/oauth-authorization-server.sample.json @@ -18,7 +18,7 @@ "https://mydomain.tld/oauth/register", "scopes_supported": ["openid", "profile", "email", "address", - "phone"], + "phone", "groups"], "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token"], diff --git a/canaille/conf/openid-configuration.sample.json b/canaille/conf/openid-configuration.sample.json index bca02049..f4986470 100644 --- a/canaille/conf/openid-configuration.sample.json +++ b/canaille/conf/openid-configuration.sample.json @@ -22,7 +22,7 @@ "https://mydomain.tld/oauth/register", "scopes_supported": ["openid", "profile", "email", "address", - "phone"], + "phone", "groups"], "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token"], diff --git a/canaille/oauth.py b/canaille/oauth.py index 978fc3ee..de3b24cc 100644 --- a/canaille/oauth.py +++ b/canaille/oauth.py @@ -27,6 +27,7 @@ CLAIMS = { "email": ("at", _("Your email address.")), "address": ("envelope open outline", _("Your postal address.")), "phone": ("phone", _("Your phone number.")), + "groups": ("users", _("Groups you are belonging to")), } diff --git a/canaille/oauth2utils.py b/canaille/oauth2utils.py index 856ef6ed..481bdb95 100644 --- a/canaille/oauth2utils.py +++ b/canaille/oauth2utils.py @@ -61,6 +61,8 @@ def generate_user_info(user, scope): fields += ["address"] if "phone" in scope: fields += ["phone_number", "phone_number_verified"] + if "groups" in scope: + fields += ["groups"] data = {} for field in fields: @@ -69,6 +71,9 @@ def generate_user_info(user, scope): data[field] = user.__getattr__(ldap_field_match) if isinstance(data[field], list): data[field] = data[field][0] + if field == "groups": + group_name_attr = current_app.config["LDAP"]["GROUP_NAME_ATTRIBUTE"] + data[field] = [getattr(g, group_name_attr)[0] for g in user.groups] return UserInfo(**data) diff --git a/demo/client/__init__.py b/demo/client/__init__.py index 437ec9d7..b2231164 100644 --- a/demo/client/__init__.py +++ b/demo/client/__init__.py @@ -17,7 +17,7 @@ def create_app(): server_metadata_url=get_well_known_url( app.config["OAUTH_AUTH_SERVER"], external=True ), - client_kwargs={"scope": "openid profile email"}, + client_kwargs={"scope": "openid profile email groups"}, ) @app.route("/") diff --git a/demo/client/templates/index.html b/demo/client/templates/index.html index 0f9455e6..fbc11288 100644 --- a/demo/client/templates/index.html +++ b/demo/client/templates/index.html @@ -53,7 +53,10 @@

{{ name }}

{% if user %} - Welcome {{ user.name }} +

Welcome {{ user.name }}

+ {% if user.groups %} +

You're a member of the following groups: {{ user.groups }}

+ {% endif %} {% else %} Welcome, please log-in. {% endif %} diff --git a/demo/conf/oauth-authorization-server.json b/demo/conf/oauth-authorization-server.json index 6f75b2ae..49ccc456 100644 --- a/demo/conf/oauth-authorization-server.json +++ b/demo/conf/oauth-authorization-server.json @@ -18,7 +18,7 @@ "http://localhost:5000/oauth/register", "scopes_supported": ["openid", "profile", "email", "address", - "phone"], + "phone", "groups"], "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token"], diff --git a/demo/conf/openid-configuration.json b/demo/conf/openid-configuration.json index e6e732a1..060c5db3 100644 --- a/demo/conf/openid-configuration.json +++ b/demo/conf/openid-configuration.json @@ -22,7 +22,7 @@ "http://localhost:5000/oauth/register", "scopes_supported": ["openid", "profile", "email", "address", - "phone"], + "phone", "groups"], "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token"], diff --git a/demo/ldif/bootstrap.ldif b/demo/ldif/bootstrap.ldif index ca26a75a..ceaef211 100644 --- a/demo/ldif/bootstrap.ldif +++ b/demo/ldif/bootstrap.ldif @@ -94,6 +94,7 @@ oauthGrantType: refresh_token oauthScope: openid oauthScope: profile oauthScope: email +oauthScope: groups oauthResponseType: code oauthResponseType: id_token oauthTokenEndpointAuthMethod: client_secret_basic @@ -111,6 +112,7 @@ oauthGrantType: refresh_token oauthScope: openid oauthScope: profile oauthScope: email +oauthScope: groups oauthResponseType: code oauthResponseType: id_token oauthTokenEndpointAuthMethod: client_secret_basic diff --git a/tests/conftest.py b/tests/conftest.py index 5fcb2d1c..ae0cf300 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -215,7 +215,7 @@ def client(app, slapd_connection): "refresh_token", ], oauthResponseType=["code", "token", "id_token"], - oauthScope=["openid", "profile"], + oauthScope=["openid", "profile", "groups"], oauthTermsOfServiceURI="https://mydomain.tld/tos", oauthPolicyURI="https://mydomain.tld/policy", oauthJWKURI="https://mydomain.tld/jwk", @@ -361,7 +361,10 @@ def foo_group(app, user, slapd_connection): g.save(slapd_connection) with app.app_context(): user.load_groups(conn=slapd_connection) - return g + yield g + user._groups = [] + g.delete(conn=slapd_connection) + @pytest.fixture @@ -375,7 +378,9 @@ def bar_group(app, admin, slapd_connection): g.save(slapd_connection) with app.app_context(): admin.load_groups(conn=slapd_connection) - return g + yield g + admin._groups = [] + g.delete(conn=slapd_connection) @pytest.fixture diff --git a/tests/test_authorization_code_flow.py b/tests/test_authorization_code_flow.py index 8390a6e2..33d62619 100644 --- a/tests/test_authorization_code_flow.py +++ b/tests/test_authorization_code_flow.py @@ -47,7 +47,7 @@ def test_authorization_code_flow(testclient, slapd_connection, logged_user, clie headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json def test_logout_login(testclient, slapd_connection, logged_user, client): @@ -105,7 +105,7 @@ def test_logout_login(testclient, slapd_connection, logged_user, client): headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json def test_refresh_token(testclient, slapd_connection, logged_user, client): @@ -164,7 +164,7 @@ def test_refresh_token(testclient, slapd_connection, logged_user, client): headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json def test_code_challenge(testclient, slapd_connection, logged_user, client): @@ -218,7 +218,7 @@ def test_code_challenge(testclient, slapd_connection, logged_user, client): headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json client.oauthTokenEndpointAuthMethod = "client_secret_basic" client.save(slapd_connection) diff --git a/tests/test_hybrid_flow.py b/tests/test_hybrid_flow.py index 17148337..479a1cef 100644 --- a/tests/test_hybrid_flow.py +++ b/tests/test_hybrid_flow.py @@ -1,9 +1,10 @@ from authlib.jose import jwt from urllib.parse import urlsplit, parse_qs -from canaille.models import AuthorizationCode, Token +from canaille.models import AuthorizationCode, Token, User def test_oauth_hybrid(testclient, slapd_connection, user, client): + User.attr_type_by_name(slapd_connection) res = testclient.get( "/oauth/authorize", params=dict( @@ -41,7 +42,7 @@ def test_oauth_hybrid(testclient, slapd_connection, user, client): headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json def test_oidc_hybrid(testclient, slapd_connection, logged_user, client, keypair): @@ -80,4 +81,4 @@ def test_oidc_hybrid(testclient, slapd_connection, logged_user, client, keypair) headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "family_name": "Doe", "sub": "user"} == res.json + assert {"name": "John Doe", "family_name": "Doe", "sub": "user", "groups": []} == res.json diff --git a/tests/test_implicit_flow.py b/tests/test_implicit_flow.py index e954a767..9fd2c50e 100644 --- a/tests/test_implicit_flow.py +++ b/tests/test_implicit_flow.py @@ -40,7 +40,7 @@ def test_oauth_implicit(testclient, slapd_connection, user, client): "/oauth/userinfo", headers={"Authorization": f"Bearer {access_token}"} ) assert "application/json" == res.content_type - assert {"name": "John Doe", "sub": "user", "family_name": "Doe"} == res.json + assert {"name": "John Doe", "sub": "user", "family_name": "Doe", "groups": []} == res.json client.oauthGrantType = ["code"] client.oauthTokenEndpointAuthMethod = "client_secret_basic" @@ -92,7 +92,60 @@ def test_oidc_implicit(testclient, keypair, slapd_connection, user, client): status=200, ) assert "application/json" == res.content_type - assert {"name": "John Doe", "sub": "user", "family_name": "Doe"} == res.json + assert {"name": "John Doe", "sub": "user", "family_name": "Doe", "groups": []} == res.json + + client.oauthGrantType = ["code"] + client.oauthTokenEndpointAuthMethod = "client_secret_basic" + client.save(slapd_connection) + + +def test_oidc_implicit_with_group(testclient, keypair, slapd_connection, user, client, foo_group): + client.oauthGrantType = ["token id_token"] + client.oauthTokenEndpointAuthMethod = "none" + + client.save(slapd_connection) + + res = testclient.get( + "/oauth/authorize", + params=dict( + response_type="id_token token", + client_id=client.oauthClientID, + scope="openid profile groups", + nonce="somenonce", + ), + ) + assert "text/html" == res.content_type + + res.form["login"] = "user" + res.form["password"] = "correct horse battery staple" + res = res.form.submit(status=302) + + res = res.follow(status=200) + assert "text/html" == res.content_type, res.json + + res = res.form.submit(name="answer", value="accept", status=302) + + assert res.location.startswith(client.oauthRedirectURIs[0]) + params = parse_qs(urlsplit(res.location).fragment) + + access_token = params["access_token"][0] + token = Token.get(access_token, conn=slapd_connection) + assert token is not None + + id_token = params["id_token"][0] + claims = jwt.decode(id_token, keypair[1]) + assert user.uid[0] == claims["sub"] + assert user.cn[0] == claims["name"] + assert [client.oauthClientID] == claims["aud"] + assert ["foo"] == claims["groups"] + + res = testclient.get( + "/oauth/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + status=200, + ) + assert "application/json" == res.content_type + assert {"name": "John Doe", "sub": "user", "family_name": "Doe", "groups": ["foo"]} == res.json client.oauthGrantType = ["code"] client.oauthTokenEndpointAuthMethod = "client_secret_basic" diff --git a/tests/test_password_flow.py b/tests/test_password_flow.py index 1a9294bf..07efe2d1 100644 --- a/tests/test_password_flow.py +++ b/tests/test_password_flow.py @@ -15,7 +15,7 @@ def test_password_flow(testclient, slapd_connection, user, client): status=200, ) - assert res.json["scope"] == "openid profile" + assert res.json["scope"] == "openid profile groups" assert res.json["token_type"] == "Bearer" access_token = res.json["access_token"] @@ -27,4 +27,4 @@ def test_password_flow(testclient, slapd_connection, user, client): headers={"Authorization": f"Bearer {access_token}"}, status=200, ) - assert {"name": "John Doe", "sub": "user", "family_name": "Doe"} == res.json + assert {"name": "John Doe", "sub": "user", "family_name": "Doe", "groups": []} == res.json From aed6b18aa8b64ebb654abb864be895ea6f79008f Mon Sep 17 00:00:00 2001 From: Camille Daniel Date: Thu, 1 Jul 2021 18:21:20 +0200 Subject: [PATCH 09/13] Show groups and enable group creation --- .gitignore | 1 + canaille/__init__.py | 2 ++ canaille/forms.py | 10 ++++++ canaille/groups.py | 62 ++++++++++++++++++++++++++++++++++ canaille/models.py | 5 +++ canaille/templates/base.html | 5 +++ canaille/templates/group.html | 58 +++++++++++++++++++++++++++++++ canaille/templates/groups.html | 19 +++++++++++ tests/conftest.py | 5 --- tests/test_groups.py | 44 ++++++++++++++++++++++-- 10 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 canaille/groups.py create mode 100644 canaille/templates/group.html create mode 100644 canaille/templates/groups.html diff --git a/.gitignore b/.gitignore index b26f8a3a..e9d0a903 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ canaille/conf/openid-configuration.json canaille/conf/*.pem canaille/conf/*.pub canaille/conf/*.key +.vscode \ No newline at end of file diff --git a/canaille/__init__.py b/canaille/__init__.py index d55e249a..e0ecfa64 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -11,6 +11,7 @@ import canaille.consents import canaille.commands.clean import canaille.oauth import canaille.account +import canaille.groups import canaille.well_known from cryptography.hazmat.primitives import serialization as crypto_serialization @@ -135,6 +136,7 @@ def setup_app(app): config_oauth(app) setup_ldap_tree(app) app.register_blueprint(canaille.account.bp) + app.register_blueprint(canaille.groups.bp, url_prefix="/groups") app.register_blueprint(canaille.oauth.bp, url_prefix="/oauth") app.register_blueprint(canaille.commands.clean.bp) app.register_blueprint(canaille.consents.bp, url_prefix="/consent") diff --git a/canaille/forms.py b/canaille/forms.py index 6160aa9f..8acebf03 100644 --- a/canaille/forms.py +++ b/canaille/forms.py @@ -145,3 +145,13 @@ def profile_form(field_names): render_kw={}, ) return wtforms.form.BaseForm(fields) + + +class GroupForm(FlaskForm): + name = wtforms.StringField( + _("Name"), + validators=[wtforms.validators.DataRequired()], + render_kw={ + "placeholder": _("group"), + }, + ) diff --git a/canaille/groups.py b/canaille/groups.py new file mode 100644 index 00000000..abb55904 --- /dev/null +++ b/canaille/groups.py @@ -0,0 +1,62 @@ +from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app, abort +from flask_babel import gettext as _ + +from .flaskutils import moderator_needed +from .forms import GroupForm +from .models import Group + +bp = Blueprint("groups", __name__) + +@bp.route("/") +@moderator_needed() +def groups(user): + groups = Group.filter(objectClass=current_app.config["LDAP"]["GROUP_CLASS"]) + return render_template("groups.html", groups=groups, menuitem="groups") + +@bp.route("/add", methods=("GET", "POST")) +@moderator_needed() +def create_group(user): + form = GroupForm(request.form or None) + try: + if "name" in form: + del form["name"].render_kw["disabled"] + except KeyError: + pass + + if request.form: + if not form.validate(): + flash(_("Group creation failed."), "error") + else: + group = Group(objectClass=current_app.config["LDAP"]["GROUP_CLASS"]) + group.member = [user.dn] + group.cn = [form.name.data] + group.save() + return redirect(url_for("groups.groups")) + + return render_template( + "group.html", + form=form, + edited_group=None, + members=None + ) + +@bp.route("/", methods=("GET", "POST")) +@moderator_needed() +def group(user, groupname): + group = Group.get(groupname) or abort(404) + form = GroupForm(request.form or None, data={"name": group.name}) + form["name"].render_kw["disabled"] = "true" + + if request.form: + if form.validate(): + group.save() + else: + flash(_("Group edition failed."), "error") + + return render_template( + "group.html", + form=form, + edited_group=group, + members=group.get_members() + ) + diff --git a/canaille/models.py b/canaille/models.py index b1b85ae8..c0b655e4 100644 --- a/canaille/models.py +++ b/canaille/models.py @@ -154,6 +154,11 @@ class Group(LDAPObject): attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") return [(group[attribute][0], group.dn) for group in groups] + @property + def name(self): + attribute = current_app.config["LDAP"].get("GROUP_NAME_ATTRIBUTE") + return self[attribute][0] + def get_members(self, conn=None): return [User.get(dn=user_dn, conn=conn) for user_dn in self.member] diff --git a/canaille/templates/base.html b/canaille/templates/base.html index fabd8afe..ef4e43ac 100644 --- a/canaille/templates/base.html +++ b/canaille/templates/base.html @@ -47,6 +47,11 @@ {% trans %}Users{% endtrans %} + + + {% trans %}Groups{% endtrans %} + {% endif %} {% if user.admin %}