diff --git a/PKG-INFO b/PKG-INFO index 8442a14..78ea47a 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,27 +1,28 @@ Metadata-Version: 2.1 Name: swh.auth -Version: 0.2.0 +Version: 0.3.0 Summary: Software Heritage Authentication Utilities Home-page: https://forge.softwareheritage.org/source/swh-auth/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh- Project-URL: Documentation, https://docs.softwareheritage.org/devel/swh-/ Description: swh-auth ========== Authentication library for SWH (keycloak common utilities) Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 3 - Alpha Requires-Python: >=3.7 Description-Content-Type: text/markdown +Provides-Extra: django Provides-Extra: testing diff --git a/mypy.ini b/mypy.ini index fb4140a..1d827b2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,15 +1,18 @@ [mypy] namespace_packages = True warn_unused_ignores = True # 3rd party libraries without stubs (yet) [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-keycloak.*] ignore_missing_imports = True + +[mypy-django.*] +ignore_missing_imports = True diff --git a/pytest.ini b/pytest.ini index b712d00..81fe35e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] norecursedirs = docs .* +DJANGO_SETTINGS_MODULE = swh.auth.tests.app.apptest.settings diff --git a/requirements-django.txt b/requirements-django.txt new file mode 100644 index 0000000..9d46956 --- /dev/null +++ b/requirements-django.txt @@ -0,0 +1 @@ +Django<3 diff --git a/requirements-test.txt b/requirements-test.txt index 5821059..af0b52a 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,2 +1,3 @@ pytest requests_mock +pytest-django diff --git a/setup.py b/setup.py index 9e6ac78..f418e2e 100755 --- a/setup.py +++ b/setup.py @@ -1,72 +1,75 @@ #!/usr/bin/env python3 # Copyright (C) 2019-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from io import open from os import path from setuptools import find_packages, setup here = path.abspath(path.dirname(__file__)) # Get the long description from the README file with open(path.join(here, "README.md"), encoding="utf-8") as f: long_description = f.read() def parse_requirements(*names): requirements = [] for name in names: if name: reqf = "requirements-%s.txt" % name else: reqf = "requirements.txt" if not path.exists(reqf): return requirements with open(reqf) as f: for line in f.readlines(): line = line.strip() if not line or line.startswith("#"): continue requirements.append(line) return requirements setup( name="swh.auth", description="Software Heritage Authentication Utilities", long_description=long_description, long_description_content_type="text/markdown", python_requires=">=3.7", author="Software Heritage developers", author_email="swh-devel@inria.fr", url="https://forge.softwareheritage.org/source/swh-auth/", packages=find_packages(), # packages's modules install_requires=parse_requirements(None, "swh"), tests_require=parse_requirements("test"), setup_requires=["setuptools-scm"], use_scm_version=True, - extras_require={"testing": parse_requirements("test")}, + extras_require={ + "django": parse_requirements("django"), + "testing": parse_requirements("test"), + }, include_package_data=True, # entry_points=""" # [swh.cli.subcommands] # =swh..cli # """, classifiers=[ "Programming Language :: Python :: 3", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Development Status :: 3 - Alpha", ], project_urls={ "Bug Reports": "https://forge.softwareheritage.org/maniphest", "Funding": "https://www.softwareheritage.org/donate", "Source": "https://forge.softwareheritage.org/source/swh-", "Documentation": "https://docs.softwareheritage.org/devel/swh-/", }, ) diff --git a/swh.auth.egg-info/PKG-INFO b/swh.auth.egg-info/PKG-INFO index 8442a14..78ea47a 100644 --- a/swh.auth.egg-info/PKG-INFO +++ b/swh.auth.egg-info/PKG-INFO @@ -1,27 +1,28 @@ Metadata-Version: 2.1 Name: swh.auth -Version: 0.2.0 +Version: 0.3.0 Summary: Software Heritage Authentication Utilities Home-page: https://forge.softwareheritage.org/source/swh-auth/ Author: Software Heritage developers Author-email: swh-devel@inria.fr License: UNKNOWN Project-URL: Bug Reports, https://forge.softwareheritage.org/maniphest Project-URL: Funding, https://www.softwareheritage.org/donate Project-URL: Source, https://forge.softwareheritage.org/source/swh- Project-URL: Documentation, https://docs.softwareheritage.org/devel/swh-/ Description: swh-auth ========== Authentication library for SWH (keycloak common utilities) Platform: UNKNOWN Classifier: Programming Language :: Python :: 3 Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Operating System :: OS Independent Classifier: Development Status :: 3 - Alpha Requires-Python: >=3.7 Description-Content-Type: text/markdown +Provides-Extra: django Provides-Extra: testing diff --git a/swh.auth.egg-info/SOURCES.txt b/swh.auth.egg-info/SOURCES.txt index 4b354fb..f6307b7 100644 --- a/swh.auth.egg-info/SOURCES.txt +++ b/swh.auth.egg-info/SOURCES.txt @@ -1,39 +1,53 @@ .gitignore .pre-commit-config.yaml AUTHORS CODE_OF_CONDUCT.md CONTRIBUTORS LICENSE MANIFEST.in Makefile README.md conftest.py mypy.ini pyproject.toml pytest.ini +requirements-django.txt requirements-swh.txt requirements-test.txt requirements.txt setup.cfg setup.py tox.ini docs/.gitignore docs/Makefile docs/conf.py docs/index.rst docs/_static/.placeholder docs/_templates/.placeholder swh/__init__.py swh.auth.egg-info/PKG-INFO swh.auth.egg-info/SOURCES.txt swh.auth.egg-info/dependency_links.txt swh.auth.egg-info/requires.txt swh.auth.egg-info/top_level.txt swh/auth/__init__.py swh/auth/cli.py swh/auth/keycloak.py swh/auth/py.typed swh/auth/pytest_plugin.py +swh/auth/django/__init__.py +swh/auth/django/models.py +swh/auth/django/utils.py swh/auth/tests/__init__.py +swh/auth/tests/conftest.py swh/auth/tests/sample_data.py -swh/auth/tests/test_keycloak.py \ No newline at end of file +swh/auth/tests/test_keycloak.py +swh/auth/tests/test_models.py +swh/auth/tests/test_utils.py +swh/auth/tests/app/__init__.py +swh/auth/tests/app/manage.py +swh/auth/tests/app/apptest/__init__.py +swh/auth/tests/app/apptest/apps.py +swh/auth/tests/app/apptest/models.py +swh/auth/tests/app/apptest/settings.py +swh/auth/tests/app/apptest/urls.py \ No newline at end of file diff --git a/swh.auth.egg-info/requires.txt b/swh.auth.egg-info/requires.txt index 511f790..6cd9810 100644 --- a/swh.auth.egg-info/requires.txt +++ b/swh.auth.egg-info/requires.txt @@ -1,6 +1,10 @@ python-keycloak>=0.19.0 swh.core[http]>=0.3 +[django] +Django<3 + [testing] pytest requests_mock +pytest-django diff --git a/swh/auth/django/__init__.py b/swh/auth/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/django/models.py b/swh/auth/django/models.py new file mode 100644 index 0000000..3863c33 --- /dev/null +++ b/swh/auth/django/models.py @@ -0,0 +1,85 @@ +# Copyright (C) 2020-2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from datetime import datetime +from typing import Optional, Set + +from django.contrib.auth.models import User + + +class OIDCUser(User): + """ + Custom User proxy model for remote users storing OpenID Connect + related data: profile containing authentication tokens. + + The model is also not saved to database as all users are already stored + in the Keycloak one. + """ + + # OIDC subject identifier + sub: str = "" + + # OIDC tokens and session related data, only relevant when a user + # authenticates from a web browser + access_token: Optional[str] = None + expires_at: Optional[datetime] = None + id_token: Optional[str] = None + refresh_token: Optional[str] = None + refresh_expires_at: Optional[datetime] = None + scope: Optional[str] = None + session_state: Optional[str] = None + + # User permissions + permissions: Set[str] + + class Meta: + # TODO: To redefine in subclass of this class + # Forced to empty otherwise, django complains about it + # "Model class swh.auth.django.OIDCUser doesn't declare an explicit app_label + # and isn't in an application in INSTALLED_APPS" + app_label = "" + proxy = True + + def save(self, **kwargs): + """ + Override django.db.models.Model.save to avoid saving the remote + users to web application database. + """ + pass + + def get_group_permissions(self, obj=None) -> Set[str]: + """ + Override django.contrib.auth.models.PermissionsMixin.get_group_permissions + to get permissions from OIDC + """ + return self.get_all_permissions(obj) + + def get_all_permissions(self, obj=None) -> Set[str]: + """ + Override django.contrib.auth.models.PermissionsMixin.get_all_permissions + to get permissions from OIDC + + """ + return self.permissions + + def has_perm(self, perm, obj=None) -> bool: + """ + Override django.contrib.auth.models.PermissionsMixin.has_perm + to check permission from OIDC + """ + if self.is_active and self.is_superuser: + return True + + return perm in self.permissions + + def has_module_perms(self, app_label) -> bool: + """ + Override django.contrib.auth.models.PermissionsMixin.has_module_perms + to check permissions from OIDC. + """ + if self.is_active and self.is_superuser: + return True + + return any(perm.startswith(app_label) for perm in self.permissions) diff --git a/swh/auth/django/utils.py b/swh/auth/django/utils.py new file mode 100644 index 0000000..31f1d8f --- /dev/null +++ b/swh/auth/django/utils.py @@ -0,0 +1,97 @@ +# Copyright (C) 2020-2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from datetime import datetime, timedelta +from typing import Any, Dict, Optional + +from swh.auth.django.models import OIDCUser +from swh.auth.keycloak import KeycloakOpenIDConnect + + +def oidc_user_from_decoded_token( + decoded_token: Dict[str, Any], client_id: Optional[str] = None +) -> OIDCUser: + """Create an OIDCUser out of a decoded token + + Args: + decoded_token: Decoded token Dict + client_id: Optional client id of the keycloak client instance used to decode + the token. If not provided, the permissions will be empty. + + Returns: + The OIDCUser instance + + """ + # compute an integer user identifier for Django User model + # by concatenating all groups of the UUID4 user identifier + # generated by Keycloak and converting it from hex to decimal + user_id = int("".join(decoded_token["sub"].split("-")), 16) + + # create a Django user that will not be saved to database + user = OIDCUser( + id=user_id, + username=decoded_token["preferred_username"], + password="", + first_name=decoded_token["given_name"], + last_name=decoded_token["family_name"], + email=decoded_token["email"], + ) + + # set is_staff user property based on groups + if "groups" in decoded_token: + user.is_staff = "/staff" in decoded_token["groups"] + + if client_id: + # extract user permissions if any + resource_access = decoded_token.get("resource_access", {}) + client_resource_access = resource_access.get(client_id, {}) + permissions = client_resource_access.get("roles", []) + else: + permissions = [] + + user.permissions = set(permissions) + + # add user sub to custom User proxy model + user.sub = decoded_token["sub"] + + return user + + +def oidc_user_from_profile( + oidc_client: KeycloakOpenIDConnect, oidc_profile: Dict[str, Any] +) -> OIDCUser: + """Initialize an OIDCUser out of an oidc profile dict. + + Args: + oidc_client: KeycloakOpenIDConnect used to discuss with keycloak + oidc_profile: OIDC profile retrieved once connected to keycloak + + Returns: + OIDCUser instance parsed out of the token received. + + """ + + # decode JWT token + decoded_token = oidc_client.decode_token(oidc_profile["access_token"]) + + # create OIDCUser from decoded token + user = oidc_user_from_decoded_token(decoded_token, client_id=oidc_client.client_id) + + # get authentication init datetime + auth_datetime = datetime.fromtimestamp(decoded_token["auth_time"]) + exp_datetime = datetime.fromtimestamp(decoded_token["exp"]) + + # compute OIDC tokens expiration date + oidc_profile["expires_at"] = exp_datetime + oidc_profile["refresh_expires_at"] = auth_datetime + timedelta( + seconds=oidc_profile["refresh_expires_in"] + ) + + # add OIDC profile data to custom User proxy model + for key, val in oidc_profile.items(): + if hasattr(user, key): + setattr(user, key, val) + + return user diff --git a/swh/auth/tests/app/__init__.py b/swh/auth/tests/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/tests/app/apptest/__init__.py b/swh/auth/tests/app/apptest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/swh/auth/tests/app/apptest/apps.py b/swh/auth/tests/app/apptest/apps.py new file mode 100644 index 0000000..9ebdc06 --- /dev/null +++ b/swh/auth/tests/app/apptest/apps.py @@ -0,0 +1,10 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.apps import AppConfig + + +class TestApp(AppConfig): + name = "swh.auth.tests.app" diff --git a/swh/auth/tests/app/apptest/models.py b/swh/auth/tests/app/apptest/models.py new file mode 100644 index 0000000..a0610e3 --- /dev/null +++ b/swh/auth/tests/app/apptest/models.py @@ -0,0 +1,19 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.db import models + +from swh.auth.django.models import OIDCUser + + +class AppUser(OIDCUser): + """AppUser class to demonstrate the use of the OIDCUser which adds some attributes. + + """ + + url = models.TextField(null=False) + + class meta: + app_label = "app-label" diff --git a/swh/auth/tests/app/apptest/settings.py b/swh/auth/tests/app/apptest/settings.py new file mode 100644 index 0000000..163c790 --- /dev/null +++ b/swh/auth/tests/app/apptest/settings.py @@ -0,0 +1,7 @@ +SECRET_KEY = "o+&ayiuk(y^wh4ijz5e=c2$$kjj7g^6r%z+8d*c0lbpfs##k#7" + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "swh.auth.tests.app.apptest.apps.TestApp", +] diff --git a/swh/auth/tests/app/apptest/urls.py b/swh/auth/tests/app/apptest/urls.py new file mode 100644 index 0000000..541ee7b --- /dev/null +++ b/swh/auth/tests/app/apptest/urls.py @@ -0,0 +1,6 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +urlpatterns = [] # type: ignore diff --git a/swh/auth/tests/app/manage.py b/swh/auth/tests/app/manage.py new file mode 100755 index 0000000..57db14f --- /dev/null +++ b/swh/auth/tests/app/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault( + "DJANGO_SETTINGS_MODULE", "swh.auth.tests.app.apptest.settings" + ) + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/swh/auth/tests/conftest.py b/swh/auth/tests/conftest.py new file mode 100644 index 0000000..9172938 --- /dev/null +++ b/swh/auth/tests/conftest.py @@ -0,0 +1,13 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + + +from swh.auth.pytest_plugin import keycloak_mock_factory +from swh.auth.tests.sample_data import CLIENT_ID, REALM_NAME, SERVER_URL + +# keycloak fixture used within tests (cf. test_keycloak.py, test_utils.py) +keycloak_mock = keycloak_mock_factory( + server_url=SERVER_URL, realm_name=REALM_NAME, client_id=CLIENT_ID, +) diff --git a/swh/auth/tests/test_keycloak.py b/swh/auth/tests/test_keycloak.py index 3bcda9f..2546b5f 100644 --- a/swh/auth/tests/test_keycloak.py +++ b/swh/auth/tests/test_keycloak.py @@ -1,164 +1,151 @@ # Copyright (C) 2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from copy import copy import os from urllib.parse import parse_qs, urlparse from keycloak.exceptions import KeycloakError import pytest import yaml from swh.auth.keycloak import KeycloakOpenIDConnect -from swh.auth.pytest_plugin import keycloak_mock_factory -from swh.auth.tests.sample_data import ( - CLIENT_ID, - DECODED_TOKEN, - OIDC_PROFILE, - REALM_NAME, - SERVER_URL, - USER_INFO, -) +from swh.auth.tests.sample_data import CLIENT_ID, DECODED_TOKEN, OIDC_PROFILE, USER_INFO from swh.core.config import read -# Make keycloak fixture to use for tests below. -keycloak_mock = keycloak_mock_factory( - server_url=SERVER_URL, realm_name=REALM_NAME, client_id=CLIENT_ID, -) - def test_keycloak_well_known(keycloak_mock): well_known_result = keycloak_mock.well_known() assert set(well_known_result.keys()) == { "issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "end_session_endpoint", "jwks_uri", "token_introspection_endpoint", } def test_keycloak_authorization_url(keycloak_mock): actual_auth_uri = keycloak_mock.authorization_url("http://redirect-uri", foo="bar") expected_auth_url = keycloak_mock.well_known()["authorization_endpoint"] parsed_result = urlparse(actual_auth_uri) assert expected_auth_url.endswith(parsed_result.path) parsed_query = parse_qs(parsed_result.query) assert parsed_query == { "client_id": [CLIENT_ID], "response_type": ["code"], "redirect_uri": ["http://redirect-uri"], "foo": ["bar"], } def test_keycloak_authorization_code_fail(keycloak_mock): "Authorization failure raise error" # Simulate failed authentication with Keycloak keycloak_mock.set_auth_success(False) with pytest.raises(KeycloakError): keycloak_mock.authorization_code("auth-code", "redirect-uri") def test_keycloak_authorization_code(keycloak_mock): actual_response = keycloak_mock.authorization_code("auth-code", "redirect-uri") assert actual_response == OIDC_PROFILE def test_keycloak_refresh_token(keycloak_mock): actual_result = keycloak_mock.refresh_token("refresh-token") assert actual_result == OIDC_PROFILE def test_keycloak_userinfo(keycloak_mock): actual_user_info = keycloak_mock.userinfo("refresh-token") assert actual_user_info == USER_INFO def test_keycloak_logout(keycloak_mock): """Login out does not raise""" keycloak_mock.logout("refresh-token") def test_keycloak_decode_token(keycloak_mock): actual_decoded_data = keycloak_mock.decode_token(OIDC_PROFILE["access_token"]) actual_decoded_data2 = copy(actual_decoded_data) expected_decoded_token = copy(DECODED_TOKEN) for dynamic_valued_key in ["exp", "auth_time"]: actual_decoded_data2.pop(dynamic_valued_key) expected_decoded_token.pop(dynamic_valued_key) assert actual_decoded_data2 == expected_decoded_token def test_keycloak_login(keycloak_mock): actual_response = keycloak_mock.login("username", "password") assert actual_response == OIDC_PROFILE @pytest.fixture def auth_config(): return { "keycloak": { "server_url": "https://auth.swh.org/SWHTest", "realm_name": "SWHTest", "client_id": "client_id", } } @pytest.fixture def auth_config_path(tmp_path, monkeypatch, auth_config): conf_path = os.path.join(tmp_path, "auth.yml") with open(conf_path, "w") as f: f.write(yaml.dump(auth_config)) monkeypatch.setenv("SWH_CONFIG_FILENAME", conf_path) return conf_path def test_auth_KeycloakOpenIDConnect_from_config(auth_config): """Instantiating keycloak client out of configuration dict is possible """ client = KeycloakOpenIDConnect.from_config(**auth_config) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == auth_config["keycloak"]["client_id"] def test_auth_KeycloakOpenIDConnect_from_configfile(auth_config_path, monkeypatch): """Instantiating keycloak client out of environment variable is possible """ client = KeycloakOpenIDConnect.from_configfile() auth_config = read(auth_config_path) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == auth_config["keycloak"]["client_id"] def test_auth_KeycloakOpenIDConnect_from_configfile_override( auth_config_path, monkeypatch ): """Instantiating keycloak client out of environment variable is possible And caller can override the configuration at calling """ client = KeycloakOpenIDConnect.from_configfile(client_id="foobar") auth_config = read(auth_config_path) assert client.server_url == auth_config["keycloak"]["server_url"] assert client.realm_name == auth_config["keycloak"]["realm_name"] assert client.client_id == "foobar" diff --git a/swh/auth/tests/test_models.py b/swh/auth/tests/test_models.py new file mode 100644 index 0000000..8f61216 --- /dev/null +++ b/swh/auth/tests/test_models.py @@ -0,0 +1,68 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from typing import Set + +import pytest + +from swh.auth.tests.app.apptest.models import AppUser + +PERMISSIONS: Set[str] = set(["api", "app-label-read"]) +NO_PERMISSION: Set[str] = set() + + +@pytest.fixture +def appuser(): + return AppUser( + id=666, + username="foo", + password="bar", + first_name="foobar", + last_name="", + email="foo@bar.org", + ) + + +@pytest.fixture +def appuser_admin(appuser): + appuser_admin = appuser + appuser_admin.is_active = True + appuser_admin.is_superuser = True + return appuser_admin + + +def test_django_appuser(appuser): + appuser.permissions = PERMISSIONS + + assert appuser.get_group_permissions() == PERMISSIONS + assert appuser.get_group_permissions(appuser) == PERMISSIONS + assert appuser.get_all_permissions() == PERMISSIONS + assert appuser.get_all_permissions(appuser) == PERMISSIONS + + assert "api" in PERMISSIONS + assert appuser.has_perm("api") is True + assert appuser.has_perm("something") is False + + assert "app-label-read" in PERMISSIONS + assert appuser.has_module_perms("app-label") is True + assert appuser.has_module_perms("app-something") is False + + +def test_django_appuser_admin(appuser_admin): + appuser_admin.permissions = NO_PERMISSION + + assert appuser_admin.get_group_permissions() == NO_PERMISSION + assert appuser_admin.get_group_permissions(appuser_admin) == NO_PERMISSION + + assert appuser_admin.get_all_permissions() == NO_PERMISSION + assert appuser_admin.get_all_permissions(appuser) == NO_PERMISSION + + assert "foobar" not in PERMISSIONS + assert appuser_admin.has_perm("foobar") is True + assert "something" not in PERMISSIONS + assert appuser_admin.has_perm("something") is True + + assert appuser_admin.has_module_perms("app-label") is True + assert appuser_admin.has_module_perms("really-whatever-app") is True diff --git a/swh/auth/tests/test_utils.py b/swh/auth/tests/test_utils.py new file mode 100644 index 0000000..64954b7 --- /dev/null +++ b/swh/auth/tests/test_utils.py @@ -0,0 +1,63 @@ +# Copyright (C) 2021 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from copy import copy +from datetime import datetime + +from swh.auth.django.utils import oidc_user_from_decoded_token, oidc_user_from_profile +from swh.auth.tests.sample_data import CLIENT_ID, DECODED_TOKEN, OIDC_PROFILE + + +def test_oidc_user_from_decoded_token(): + user = oidc_user_from_decoded_token(DECODED_TOKEN) + + assert user.id == 338521271020811424925120118444075479552 + assert user.username == "johndoe" + assert user.password == "" + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user.email == "john.doe@example.com" + assert user.is_staff is False + assert user.permissions == set() + assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" + + +def test_oidc_user_from_decoded_token2(): + decoded_token = copy(DECODED_TOKEN) + decoded_token["groups"] = ["/staff", "api"] + decoded_token["resource_access"] = {CLIENT_ID: {"roles": ["read-api"]}} + + user = oidc_user_from_decoded_token(decoded_token, client_id=CLIENT_ID) + + assert user.id == 338521271020811424925120118444075479552 + assert user.username == "johndoe" + assert user.password == "" + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user.email == "john.doe@example.com" + assert user.is_staff is True + assert user.permissions == {"read-api"} + assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" + + +def test_oidc_user_from_profile(keycloak_mock): + date_now = datetime.now() + + user = oidc_user_from_profile(keycloak_mock, OIDC_PROFILE) + + assert user.id == 338521271020811424925120118444075479552 + assert user.username == "johndoe" + assert user.password == "" + assert user.first_name == "John" + assert user.last_name == "Doe" + assert user.email == "john.doe@example.com" + assert user.is_staff is False + assert user.permissions == set() + assert user.sub == "feacd344-b468-4a65-a236-14f61e6b7200" + + assert isinstance(user.expires_at, datetime) + assert date_now <= user.expires_at + assert isinstance(user.refresh_expires_at, datetime) + assert date_now <= user.refresh_expires_at diff --git a/tox.ini b/tox.ini index de9dbf7..214b632 100644 --- a/tox.ini +++ b/tox.ini @@ -1,36 +1,37 @@ [tox] envlist=black,flake8,mypy,py3 [testenv] extras = testing + django deps = pytest-cov dev: pdbpp commands = pytest --doctest-modules \ {envsitepackagesdir}/swh/auth \ --cov={envsitepackagesdir}/swh/auth \ --cov-branch {posargs} [testenv:black] skip_install = true deps = black==19.10b0 commands = {envpython} -m black --check swh [testenv:flake8] skip_install = true deps = flake8 commands = {envpython} -m flake8 [testenv:mypy] extras = testing deps = mypy commands = mypy swh