diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2c419e6204d81899cd66f0dfaa48aee81d3d44b..df00c3fac0cf86203e80478ed1b43b9fc52dce1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +Unreleased +---------- + +Added +~~~~~ + +* Allow apps to dynamically generate OAuth scopes + +Removed +~~~~~~~ + +* `OAUTH2_SCOPES` setting in apps is not supported anymore. Use `get_all_scopes` method + on `AppConfig` class instead. + + `2.0rc4`_ - 2021-08-01 ---------------------- diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py index 9679c309ed9e6280495b420651e6c17fe56d3daf..d9a3f38fa379ddebb10073f7b5afd53301984da1 100644 --- a/aleksis/core/apps.py +++ b/aleksis/core/apps.py @@ -2,6 +2,7 @@ from typing import Any, Optional import django.apps from django.apps import apps +from django.conf import settings from django.http import HttpRequest from django.utils.module_loading import autodiscover_modules @@ -136,3 +137,18 @@ class CoreConfig(AppConfig): if has_person(user): # Save the associated person to pick up defaults user.person.save() + + def get_all_scopes(self) -> dict[str, str]: + scopes = { + "read": "Read anything the resource owner can read", + "write": "Write anything the resource owner can write", + } + if settings.OAUTH2_PROVIDER.get("OIDC_ENABLED", False): + scopes |= { + "openid": _("OpenID Connect scope"), + "profile": _("Given name, family name, link to profile and picture if existing."), + "address": _("Full home postal address"), + "email": _("Email address"), + "phone": _("Home and mobile phone"), + } + return scopes diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index faa5bcf1da53c97bd878c33bced3e9e2a6e384a8..fbd436ae1d69c3d705a9a3ec9896538974f02eb6 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -324,13 +324,7 @@ ACCOUNT_UNIQUE_EMAIL = _settings.get("auth.login.registration.unique_email", Tru # Configuration for OAuth2 provider -OAUTH2_PROVIDER = { - "SCOPES": { - "read": "Read anything the resource owner can read", - "write": "Write anything the resource owner can write", - } -} -merge_app_settings("OAUTH2_SCOPES", OAUTH2_PROVIDER["SCOPES"], True) +OAUTH2_PROVIDER = {"SCOPES_BACKEND_CLASS": "aleksis.core.util.auth_helpers.AppScopes"} if _settings.get("oauth2.oidc.enabled", False): with open(_settings.get("oauth2.oidc.rsa_key", "/etc/aleksis/oidc.pem"), "r") as f: diff --git a/aleksis/core/util/apps.py b/aleksis/core/util/apps.py index 5f597af29b10363ca003622e347bc76380864efd..5c31ae54acdb711d42084ac9ee4fd52d0335729a 100644 --- a/aleksis/core/util/apps.py +++ b/aleksis/core/util/apps.py @@ -1,5 +1,5 @@ from importlib import metadata -from typing import Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Optional, Sequence import django.apps from django.contrib.auth.signals import user_logged_in, user_logged_out @@ -12,6 +12,9 @@ from spdx_license_list import LICENSES from .core_helpers import copyright_years +if TYPE_CHECKING: + from oauth2_provider.models import AbstractApplication + class AppConfig(django.apps.AppConfig): """An extended version of DJango's AppConfig container.""" @@ -214,6 +217,30 @@ class AppConfig(django.apps.AppConfig): """ pass + def get_all_scopes(self) -> dict[str, str]: + """Return all OAuth scopes and their descriptions for this app.""" + return {} + + def get_available_scopes( + self, + application: Optional["AbstractApplication"] = None, + request: Optional[HttpRequest] = None, + *args, + **kwargs, + ) -> list[str]: + """Return a list of all OAuth scopes available to the request and application.""" + return list(self.get_all_scopes().keys()) + + def get_default_scopes( + self, + application: Optional["AbstractApplication"] = None, + request: Optional[HttpRequest] = None, + *args, + **kwargs, + ) -> list[str]: + """Return a list of all OAuth scopes to always include for this request and application.""" + return [] + def _maintain_default_data(self): from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType diff --git a/aleksis/core/util/auth_helpers.py b/aleksis/core/util/auth_helpers.py index ed7bced95ac2a83c32d2919ad484204448f71e6f..8caea1659fd821f10229e5339adffd5a56659c99 100644 --- a/aleksis/core/util/auth_helpers.py +++ b/aleksis/core/util/auth_helpers.py @@ -1,12 +1,17 @@ """Helpers/overrides for django-allauth.""" +from typing import Optional + from django.conf import settings from django.http import HttpRequest from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from oauth2_provider.models import AbstractApplication from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.scopes import BaseScopes +from .apps import AppConfig from .core_helpers import get_site_preferences, has_person @@ -73,3 +78,41 @@ class CustomOAuth2Validator(OAuth2Validator): } return claims + + +class AppScopes(BaseScopes): + """Scopes backend for django-oauth-toolkit gathering scopes from apps. + + Will call the respective method on all known AlekSIS app configs and + join the results. + """ + + def get_all_scopes(self) -> dict[str, str]: + scopes = {} + for app in AppConfig.__subclasses__(): + scopes |= app.get_all_scopes() + return scopes + + def get_available_scopes( + self, + application: Optional[AbstractApplication] = None, + request: Optional[HttpRequest] = None, + *args, + **kwargs + ) -> list[str]: + scopes = [] + for app in AppConfig.__subclasses__(): + scopes += app.get_available_scopes() + return scopes + + def get_default_scopes( + self, + application: Optional[AbstractApplication] = None, + request: Optional[HttpRequest] = None, + *args, + **kwargs + ) -> list[str]: + scopes = [] + for app in AppConfig.__subclasses__(): + scopes += app.get_default_scopes() + return scopes