From 45076cc603fbd9f1844f795c7858c35d42fbcea8 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Mon, 29 Sep 2025 10:56:58 -0700 Subject: [PATCH] oidc: New app to implement OpenID Connect Provider - Add a component to easily manage registration of client applications. Tests: - Package build is successful has dependency on python3-django-auto-toolkit - python3-django-oauth-toolkit can be installed on stable, testing and unstable containers - /.well-known/openid-configuration and /.well-known/jwks.json are servered properly. - /o/ URLs don't require login to access - When logging in list of claims includes 'sub', email, freedombox_groups. - Logging in using IP address works. Also works with a port. - Logging in using 127.0.0.1 address works. Also works with a port. - Logging in using localhost works. Also works with a port. - Logging in with IPv6 address works. Also works with a port. - Logging in with IPv6 [::1] address works. Also works with a port. - Logging in with IPv6 link-local address with zone ID is not possible (as browsers don't support them). - When authorization page is enabled, scopes show description as expected. - When domain name is added/removed, all OIDC components are updated with expected domains Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- .../apache2/conf-available/freedombox.conf | 12 ++ debian/control | 2 + plinth/middleware.py | 20 ++- plinth/modules/names/__init__.py | 17 ++ plinth/modules/oidc/__init__.py | 79 ++++++++ plinth/modules/oidc/components.py | 169 ++++++++++++++++++ .../usr/share/freedombox/modules-enabled/oidc | 1 + plinth/modules/oidc/tests/__init__.py | 0 plinth/modules/oidc/tests/test_components.py | 104 +++++++++++ plinth/modules/oidc/urls.py | 31 ++++ plinth/modules/oidc/validators.py | 124 +++++++++++++ plinth/settings.py | 19 +- plinth/tests/data/django_test_settings.py | 3 + plinth/tests/test_middleware.py | 10 ++ plinth/urls.py | 9 +- pyproject.toml | 1 + 16 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 plinth/modules/oidc/__init__.py create mode 100644 plinth/modules/oidc/components.py create mode 100644 plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc create mode 100644 plinth/modules/oidc/tests/__init__.py create mode 100644 plinth/modules/oidc/tests/test_components.py create mode 100644 plinth/modules/oidc/urls.py create mode 100644 plinth/modules/oidc/validators.py diff --git a/data/etc/apache2/conf-available/freedombox.conf b/data/etc/apache2/conf-available/freedombox.conf index 645d11173..292189481 100644 --- a/data/etc/apache2/conf-available/freedombox.conf +++ b/data/etc/apache2/conf-available/freedombox.conf @@ -76,6 +76,18 @@ RequestHeader set X-Forwarded-Proto 'https' env=HTTPS RequestHeader unset X-Forwarded-For + + ProxyPass http://127.0.0.1:8000/freedombox/o/.well-known/openid-configuration + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto 'https' env=HTTPS + RequestHeader unset X-Forwarded-For + + + ProxyPass http://127.0.0.1:8000/freedombox/o/.well-known/jwks.json + ProxyPreserveHost On + RequestHeader set X-Forwarded-Proto 'https' env=HTTPS + RequestHeader unset X-Forwarded-For + ## ## Serve FreedomBox icon as /favicon.ico for apps that don't present their own diff --git a/debian/control b/debian/control index 5a45dd0b9..854dbeba5 100644 --- a/debian/control +++ b/debian/control @@ -35,6 +35,7 @@ Build-Depends: python3-django-captcha, # Explictly depend on ipware as it is optional dependecy of django-axes python3-django-ipware, + python3-django-oauth-toolkit, python3-django-stronghold, python3-gi, python3-markupsafe, @@ -108,6 +109,7 @@ Depends: python3-django-captcha, # Explictly depend on ipware as it is optional dependecy of django-axes python3-django-ipware, + python3-django-oauth-toolkit, python3-django-stronghold, python3-gi, python3-markupsafe, diff --git a/plinth/middleware.py b/plinth/middleware.py index 13e39d7e9..aeee61853 100644 --- a/plinth/middleware.py +++ b/plinth/middleware.py @@ -4,6 +4,7 @@ Common Django middleware. """ import logging +import re from django import urls from django.conf import settings @@ -58,7 +59,9 @@ class SetupMiddleware(MiddlewareMixin): except urls.Resolver404: return - if not resolver_match.namespaces or not len(resolver_match.namespaces): + non_app_namespaces = {'oauth2_provider'} + if (not resolver_match.namespaces or not len(resolver_match.namespaces) + or (set(resolver_match.namespaces) & non_app_namespaces)): # Requested URL does not belong to any application return @@ -99,10 +102,17 @@ class AdminRequiredMiddleware(MiddlewareMixin): hasattr(view_func, 'IS_NON_ADMIN'): return - if not is_user_admin(request): - if not AdminRequiredMiddleware.check_user_group( - view_func, request): - raise PermissionDenied + public_urls = settings.STRONGHOLD_PUBLIC_URLS + if any(re.match(url, request.path_info) for url in public_urls): + return + + if is_user_admin(request): + return + + if AdminRequiredMiddleware.check_user_group(view_func, request): + return + + raise PermissionDenied class FirstSetupMiddleware(MiddlewareMixin): diff --git a/plinth/modules/names/__init__.py b/plinth/modules/names/__init__.py index 2a80fa3b8..8e1e309ba 100644 --- a/plinth/modules/names/__init__.py +++ b/plinth/modules/names/__init__.py @@ -251,6 +251,15 @@ def on_domain_added(sender: str, domain_type: str, name: str = '', logger.info('Added domain %s of type %s with services %s', name, domain_type, str(services)) + # HACK: Call from here to here ensure that the on_domain_added can perform + # DomainName.list() iteration and get an updated list of domains. This + # won't happen if in the signal calling order, oidc module get notified + # first. A proper fix is being worked on as a complete overhaul to domain + # change notification mechanism. + from plinth.modules import oidc + oidc.on_domain_added(sender, domain_type, name, description, services, + **kwargs) + def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs): """Remove domain from global list.""" @@ -258,6 +267,14 @@ def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs): component_id = 'domain-' + sender + '-' + name components.DomainName.get(component_id).remove() logger.info('Removed domain %s of type %s', name, domain_type) + + # HACK: Call from here to here ensure that the on_domain_remove can + # perform DomainName.list() iteration and get an updated list of + # domains. This won't happen if in the signal calling order, oidc + # module get notified first. A proper fix is being worked on as a + # complete overhaul to domain change notification mechanism. + from plinth.modules import oidc + oidc.on_domain_removed(sender, domain_type, name, **kwargs) else: for domain_name in components.DomainName.list(): if domain_name.domain_type.component_id == domain_type: diff --git a/plinth/modules/oidc/__init__.py b/plinth/modules/oidc/__init__.py new file mode 100644 index 000000000..5c593cd0d --- /dev/null +++ b/plinth/modules/oidc/__init__.py @@ -0,0 +1,79 @@ +"""FreedomBox app for implementing a OpenID Connect Provider. + +With this app, FreedomBox implements a full OpenID Connect Provider along with +OpenID Discovery. Only authorization code grant type is currently supported but +can be easily extended to support other grant types if necessary. See this code +comment for quick understand of the flow works: +https://github.com/oauthlib/oauthlib/blob/master/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py#L64 + +In the list of OpenID Connect claims provided to Relying Party, we override +django-oauth-toolkit's default 'sub' claim from being the user ID to username. +Additionally, we also provide basic profile information and 'freedombox_groups' +with list of all group names that the user account is part of. + +"Clients" or "Applications": must be registered before they can be used with +the Identity Provider. Dynamic Client Registration is not supported yet. The +OpenIDConnect FreedomBox component will help with this registration. + +Redirect URLs: After authorization is provided by the user, the URL of the +application to redirect to must be verified by the Identity Provider. This is +usually provided at the time of client registration. However, in FreedomBox, +since list of allowed domains keeps changing, the list of allowed redirect URLs +must keep changing as well. The OpenIDConnect component will also help with +that. Finally, there is overridden verification logic that ensures that +accessing protected applications using IP addresses or localhost domain names +in URLs is allowed. + +The implement is done by implementing all the URLs in /freedombox/o. Most of +the implementation for these views and models is provided by +django-oauth-toolkit. +""" + +import logging + +from django.utils.translation import gettext_lazy as _ + +from plinth import app as app_module + +from . import components + +logger = logging.getLogger(__name__) + + +class OIDCApp(app_module.App): + """FreedomBox app for OpenID Connect Provider.""" + + app_id = 'oidc' + + _version = 1 + + def __init__(self) -> None: + """Create components for the app.""" + super().__init__() + + info = app_module.Info(app_id=self.app_id, version=self._version, + is_essential=True, + name=_('OpenID Connect Provider')) + self.add(info) + + +def on_domain_added(sender: str, domain_type: str, name: str = '', + description: str = '', + services: str | list[str] | None = None, **kwargs): + """Add domain to global list.""" + if not domain_type or not name: + return + + logger.info('Updating all OpenID Connect components for domain add - %s', + name) + components.OpenIDConnect.update_domains_for_all() + + +def on_domain_removed(sender: str, domain_type: str, name: str = '', **kwargs): + """Remove domain from global list.""" + if not domain_type or not name: + return + + logger.info( + 'Updating all OpenID Connect components for domain remove - %s', name) + components.OpenIDConnect.update_domains_for_all() diff --git a/plinth/modules/oidc/components.py b/plinth/modules/oidc/components.py new file mode 100644 index 000000000..8449e960f --- /dev/null +++ b/plinth/modules/oidc/components.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""App component for other apps to authenticate with OpenID Connect.""" + +from plinth import app as app_module + + +class OpenIDConnect(app_module.FollowerComponent): + """Component to authentication with OpenID Connection.""" + + def __init__(self, component_id: str, client_id: str, name: str, + redirect_uris: list[str], skip_authorization: bool = False): + """Initialize the OpenID Connect component. + + name is a description name for the client that is only used for record + keeping. + + client_id is ID to use when registring the app as client (relying + party) with FreedomBox's OpenID Connect Provider. + + redirect_uris is list of string containing URIs that the user's agent + may be redirected to after successful authentication. If the URIs + contain {domain} in their string, it will be expanded to the list of + all domains configured in FreedomBox. + """ + super().__init__(component_id) + + self.client_id = client_id + self.name = name + self.redirect_uris = redirect_uris + self.post_logout_redirect_uris = '' # None is not allowed + self._client_type = None + self._authorization_grant_type = None + self._algorithm = None + self.hash_client_secret = False + self.skip_authorization = skip_authorization + + @property + def client_type(self): + """Return the client type. + + This is a property instead of a simple attribute to avoid importing + Application model during component.__init__(). This would require + Django to be configured. + """ + from oauth2_provider.models import Application + return self._client_type or Application.CLIENT_CONFIDENTIAL + + @client_type.setter + def client_type(self, value): + """Set the client type.""" + self._client_type = value + + @property + def authorization_grant_type(self): + """Return the authorization grant type. + + This is a property instead of a simple attribute to avoid importing + Application model during component.__init__(). This would require + Django to be configured. + """ + from oauth2_provider.models import Application + return (self._authorization_grant_type + or Application.GRANT_AUTHORIZATION_CODE) + + @authorization_grant_type.setter + def authorization_grant_type(self, value): + """Set the authorization grant type.""" + self._authorization_grant_type = value + + @property + def algorithm(self): + """Return the algorithm. + + This is a property instead of a simple attribute to avoid importing + Application model during component.__init__(). This would require + Django to be configured. + """ + from oauth2_provider.models import Application + return self._algorithm or Application.HS256_ALGORITHM + + @algorithm.setter + def algorithm(self, value): + """Set the algorithm.""" + self._algorithm = value + + def get_client_secret(self): + """Return the client secret stored for the application.""" + from oauth2_provider.models import Application + return Application.objects.get_by_natural_key( + self.client_id).client_secret + + def setup(self, old_version: int) -> None: + """Register the app as client.""" + self._create_or_update_application() + + @staticmethod + def update_domains_for_all(): + """For all app components, update redirect URIs and allowed origins.""" + for app in app_module.App.list(): + for component in app.components.values(): + if isinstance(component, OpenIDConnect): + component._create_or_update_application() + + def _create_or_update_application(self) -> None: + """Register the app as client.""" + from oauth2_provider.models import Application + try: + application = Application.objects.get_by_natural_key( + self.client_id) + self._update_application(application) + except Application.DoesNotExist: + self._create_application() + + def _create_application(self) -> None: + """Create a new application object.""" + from oauth2_provider import generators + from oauth2_provider.models import Application + client_secret = generators.generate_client_secret() + Application.objects.create( + client_id=self.client_id, client_secret=client_secret, user=None, + redirect_uris=self._get_redirect_uris(), + post_logout_redirect_uris=self.post_logout_redirect_uris, + client_type=self.client_type, + authorization_grant_type=self.authorization_grant_type, + hash_client_secret=self.hash_client_secret, name=str(self.name), + algorithm=self.algorithm, + allowed_origins=self._get_allowed_origins(), + skip_authorization=self.skip_authorization) + + def _update_application(self, application) -> None: + """Update configuration for an existing application.""" + application.user = None + application.redirect_uris = self._get_redirect_uris() + application.post_logout_redirect_uris = self.post_logout_redirect_uris + application.client_type = self.client_type + application.authorization_grant_type = self.authorization_grant_type + application.hash_client_secret = self.hash_client_secret + application.name = str(self.name) + application.algorithm = self.algorithm + application.allowed_origins = self._get_allowed_origins() + application.skip_authorization = self.skip_authorization + application.save() + + def _get_redirect_uris(self) -> str: + """Return an expanded list of redirect URIs.""" + from plinth.modules.names.components import DomainName + final_uris = [] + # redirect_uris list can't be empty. Otherwise, validations for + # 'localhost' and IP addresses won't work. + domains = set(DomainName.list_names()) | {'localhost'} + for uri in self.redirect_uris: + if '{domain}' in uri: + for domain in domains: + final_uris.append(uri.format(domain=domain)) + else: + final_uris.append(uri) + + return ' '.join(final_uris) + + def _get_allowed_origins(self) -> str: + """Return a list of all allowed origins for CORS header.""" + from plinth.modules.names.components import DomainName + + # redirect_uris list can't be empty. Otherwise, validations for + # 'localhost' and IP addresses won't work. Keep origins in line with + # redirect_uris. + domains = set(DomainName.list_names()) | {'localhost'} + origins = [f'https://{domain_name}' for domain_name in domains] + return ' '.join(origins) diff --git a/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc b/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc new file mode 100644 index 000000000..2060a32b2 --- /dev/null +++ b/plinth/modules/oidc/data/usr/share/freedombox/modules-enabled/oidc @@ -0,0 +1 @@ +plinth.modules.oidc diff --git a/plinth/modules/oidc/tests/__init__.py b/plinth/modules/oidc/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/oidc/tests/test_components.py b/plinth/modules/oidc/tests/test_components.py new file mode 100644 index 000000000..4c8349a3b --- /dev/null +++ b/plinth/modules/oidc/tests/test_components.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Test the components provides by oidc app.""" + +from unittest.mock import patch + +import pytest +from oauth2_provider.models import Application + +from ..components import OpenIDConnect + + +@pytest.fixture(name='openid_connect') +def fixture_openid_connect(): + """Return an OpenIDConnect object.""" + return OpenIDConnect( + 'test-openidconnect', 'test-client', 'Test Client', + ['https://testdomain.example/a', 'https://{domain}/b']) + + +def test_openidconnect_init(): + """Test initialization of openidconnect object.""" + component = OpenIDConnect('test-openidconnect', 'test-client', + 'Test Client', ['https://testdomain.example/a']) + assert component.component_id == 'test-openidconnect' + assert component.client_id == 'test-client' + assert component.name == 'Test Client' + assert component.redirect_uris == ['https://testdomain.example/a'] + assert component.post_logout_redirect_uris == '' + assert component.client_type == Application.CLIENT_CONFIDENTIAL + assert component.authorization_grant_type == \ + Application.GRANT_AUTHORIZATION_CODE + assert component.algorithm == Application.HS256_ALGORITHM + assert not component.hash_client_secret + assert not component.skip_authorization + + component = OpenIDConnect('test-openidconnect', 'test-client', + 'Test Client', ['https://testdomain.example/a'], + skip_authorization=True) + assert component.skip_authorization + + +@pytest.mark.django_db +def test_get_client_secret(openid_connect): + """Test retrieving client secret.""" + with pytest.raises(Application.DoesNotExist): + openid_connect.get_client_secret() + + openid_connect.setup(old_version=0) + assert len(openid_connect.get_client_secret()) == 128 + + +@pytest.mark.django_db +@patch('plinth.modules.names.components.DomainName.list_names') +def test_setup(list_names, openid_connect): + """Test creating a DB object.""" + list_names.return_value = ('a.example', 'b.example') + expected_redirect_uris = [ + 'https://testdomain.example/a', 'https://a.example/b', + 'https://b.example/b', 'https://localhost/b' + ] + expected_origins = [ + 'https://a.example', 'https://b.example', 'https://localhost' + ] + + # Test creating fresh DB entry + openid_connect.setup(old_version=0) + obj = Application.objects.get(client_id=openid_connect.client_id) + assert obj.client_id == openid_connect.client_id + assert len(obj.client_secret) == 128 + assert not obj.user + assert set(obj.redirect_uris.split(' ')) == set(expected_redirect_uris) + assert obj.post_logout_redirect_uris == '' + assert obj.client_type == Application.CLIENT_CONFIDENTIAL + assert obj.authorization_grant_type == Application.GRANT_AUTHORIZATION_CODE + assert not obj.hash_client_secret + assert obj.name == 'Test Client' + assert obj.algorithm == Application.HS256_ALGORITHM + assert set(obj.allowed_origins.split(' ')) == set(expected_origins) + assert not obj.skip_authorization + + # Test updating existing DB entry + list_names.return_value = ('c.example', ) + expected_redirect_uris = ['https://c.example/c', 'https://localhost/c'] + expected_origins = ['https://c.example', 'https://localhost'] + openid_connect.redirect_uris = ['https://{domain}/c'] + openid_connect.allowed_origins = expected_origins + openid_connect.post_logout_redirect_uris = 'a b' + openid_connect.client_type = Application.CLIENT_PUBLIC + openid_connect.authorization_grant_type = Application.GRANT_IMPLICIT + openid_connect.hash_client_secret = True + openid_connect.name = 'New name' + openid_connect.algorithm = Application.RS256_ALGORITHM + openid_connect.skip_authorization = True + openid_connect.setup(old_version=0) + obj = Application.objects.get(client_id=openid_connect.client_id) + assert set(obj.redirect_uris.split(' ')) == set(expected_redirect_uris) + assert set(obj.allowed_origins.split(' ')) == set(expected_origins) + assert obj.post_logout_redirect_uris == 'a b' + assert obj.client_type == Application.CLIENT_PUBLIC + assert obj.authorization_grant_type == Application.GRANT_IMPLICIT + assert obj.hash_client_secret + assert obj.name == 'New name' + assert obj.algorithm == Application.RS256_ALGORITHM + assert obj.skip_authorization diff --git a/plinth/modules/oidc/urls.py b/plinth/modules/oidc/urls.py new file mode 100644 index 000000000..d82ec98ca --- /dev/null +++ b/plinth/modules/oidc/urls.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""URLs for the OpenID Connect module. + +All the '/freedombox/o' URLs are implemented in this module by including them +from django-oauth-toolkit. However, they are included in plinth/urls.py instead +of here because FreedomBox module loading logic automatically namespaces the +URL names. This causes problems when metadata view tries to resolve URLs. + +/.well-known/openid-configuration is proxied to +/freedombox/o/.well-known/openid-configuration by Apache2. Similarly, +/.well-known/jwks.json is proxied to /freedombox/o/.well-known/jwks.json. + +Important URLs: + +- /freedombox/o is the primary URL for identity provider. + +- /freedombox/o/.well-known/openid-configuration is the way to discover +additional URLs (such as ./authorize and ./token) needed for OIDC to work. + +- /freedombox/o/authorize is used to start the authorization process and get an +authorization code grant. + +- /freedombox/o/token is used to get access token and refresh token using the +authorization code. It is also used to get a new access token using the refresh +token. + +- /freedombox/o/userinfo provides the claims such as 'sub', 'email', +'freedombox_groups' using an access token. +""" + +urlpatterns: list = [] diff --git a/plinth/modules/oidc/validators.py b/plinth/modules/oidc/validators.py new file mode 100644 index 000000000..0003539c4 --- /dev/null +++ b/plinth/modules/oidc/validators.py @@ -0,0 +1,124 @@ +"""Custom OpenID Connect validators.""" + +import ipaddress +import urllib.parse + +from oauth2_provider import oauth2_validators + +from plinth import action_utils + + +class OAuth2Validator(oauth2_validators.OAuth2Validator): + """Add/override claims into Discovery and ID token. + + Ensure that basic profile information is available to the clients. Make the + value of the 'sub' claim, as defined in OpenID Connect, to be the username + of the account instead of the Django account ID. The username is unique in + FreedomBox. + + We wish for the applications using the Identity Provider to also + provide/deny resources based on the groups that the user is part of. For + this, we add an additional scope "freedombox_groups" and additional claim + "freedombox_groups". To define custom scopes and claims, we need to ensure + that the keys used are unique and will not clash with other + implementations. 'freedombox_' prefix seems reasonable. The value of this + claim is a list of all groups that the user account is part of. + """ + + # Add a scope, as recommended in the oauth-toolkit documentation. + oidc_claim_scope = oauth2_validators.OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({'freedombox_groups': 'freedombox_groups'}) + + def get_additional_claims(self): + """Override value of 'sub' claim and add other claims. + + Only the 'sub' claim is filled by default by django-oauth-toolkit. The + rest, as needed, must be filled by us. + + Use the 'second' form of get_additional_claims override as + documentation suggests so that the base code and automatically add the + list of claims returned here to list of supported claims. + """ + + def _get_user_groups(request): + return list(request.user.groups.values_list('name', flat=True)) + + return { + 'sub': lambda request: request.user.username, + 'email': lambda request: request.user.email, + 'preferred_username': lambda request: request.user.username, + 'freedombox_groups': _get_user_groups, + } + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, + **kwargs): + """Additionally allow redirection to this server's IPs and domains.""" + allowed_redirect_uris = request.client.redirect_uris.split() + if _validate_local_domains_and_ips(redirect_uri, request, + allowed_redirect_uris): + return True + + return super().validate_redirect_uri(client_id, redirect_uri, request, + *args, **kwargs) + + +def _validate_local_domains_and_ips(redirect_uri, request, + allowed_redirect_uris): + """Allow redirect to local domains and IPs. + + See models.py:redirect_to_uri_allowed() in django-oauth-toolkit for + reference. + + Path and query part of the redirect URL must always match with one of the + configured redirect URLs for this application. The query in the redirect + URI is allowed to be a subset of the allowed query. + + Localhost domains (localhost, ip6-localhost, and ip6-loopback) are allowed + in redirect URLs. Scheme and port are not checked. + + An IP address based redirect URL is accepted as long as it is to the same + IP address with which the FreedomBox's Identity Provider is being accessed. + Scheme is not checked. Changing IP address during OpenID Connect flow is + not allowed. + """ + request_host = request.headers.get('HTTP_HOST') + + parsed_redirect_uri = urllib.parse.urlparse(redirect_uri) + + redirect_uri_query_set = set( + urllib.parse.parse_qs(parsed_redirect_uri.query)) + try: + ipaddress.ip_address(parsed_redirect_uri.hostname) + redirect_uri_is_ip = True + except ValueError: + redirect_uri_is_ip = False + + redirect_uri_is_localhost = parsed_redirect_uri.hostname in ( + 'localhost', 'ip6-localhost', 'ip6-loopback', + action_utils.get_hostname()) + + for allowed_uri in allowed_redirect_uris: + parsed_allowed_uri = urllib.parse.urlparse(allowed_uri) + + # Path must match one of the allowed paths + if parsed_redirect_uri.path != parsed_allowed_uri.path: + continue + + # Query must be a subset of allowed query + allowed_query_set = set(urllib.parse.parse_qs( + parsed_allowed_uri.query)) + if not allowed_query_set.issubset(redirect_uri_query_set): + continue + + # If the redirect is to an IP address, it is only allowed if the IDP + # itself is being accessed with that IP address. + if (redirect_uri_is_ip and request_host + and parsed_redirect_uri.netloc == request_host): + return True + + # If the redirect is to a 'localhost' like address, a port mismatch is + # allowed. + if redirect_uri_is_localhost: + return True + + return False # Special criteria didn't match, do usual checks. diff --git a/plinth/settings.py b/plinth/settings.py index 436e5f277..50c303942 100644 --- a/plinth/settings.py +++ b/plinth/settings.py @@ -27,6 +27,7 @@ See: https://docs.djangoproject.com/en/dev/ref/settings/ """ import django +from django.utils.translation import gettext_lazy as _ ALLOWED_HOSTS = ['*'] @@ -125,6 +126,7 @@ INSTALLED_APPS = [ 'django.contrib.contenttypes', 'django.contrib.messages', 'django.contrib.sessions', + 'oauth2_provider', 'stronghold', 'plinth', ] @@ -171,6 +173,21 @@ MIDDLEWARE = ( 'axes.middleware.AxesMiddleware', ) +OAUTH2_PROVIDER = { + 'OIDC_ENABLED': True, + 'OAUTH2_VALIDATOR_CLASS': 'plinth.modules.oidc.validators.OAuth2Validator', + 'SCOPES': { + 'openid': + _('Uniquely identify your user account with username'), + 'email': + _('View email address'), + 'profile': + _('View basic profile information (such as name and email)'), + 'freedombox_groups': + _('View permissions that account has') + }, +} + PASSWORD_HASHERS = [ 'plinth.hashers.Argon2PasswordHasherLowMemory', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', @@ -192,7 +209,7 @@ SESSION_FILE_PATH = '/var/lib/plinth/sessions' # Overridden based on configuration key server_dir STATIC_URL = '/freedombox/static/' -# STRONGHOLD_PUBLIC_URLS=(r'^captcha/', ) +STRONGHOLD_PUBLIC_URLS = (r'^/o/', ) STRONGHOLD_PUBLIC_NAMED_URLS = ( 'captcha-image', diff --git a/plinth/tests/data/django_test_settings.py b/plinth/tests/data/django_test_settings.py index 0f742f4e3..9808b00b8 100644 --- a/plinth/tests/data/django_test_settings.py +++ b/plinth/tests/data/django_test_settings.py @@ -35,6 +35,7 @@ INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.messages', + 'oauth2_provider', 'stronghold', 'plinth', ] @@ -51,6 +52,8 @@ ROOT_URLCONF = 'plinth.tests.data.urls' SECRET_KEY = 'django_tests_secret_key' +STRONGHOLD_PUBLIC_URLS = (r'^/o/', ) + TEMPLATES = [{ 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, diff --git a/plinth/tests/test_middleware.py b/plinth/tests/test_middleware.py index 47d46ab24..7895ba51a 100644 --- a/plinth/tests/test_middleware.py +++ b/plinth/tests/test_middleware.py @@ -257,6 +257,16 @@ class TestAdminMiddleware: response = middleware.process_view(web_request, **kwargs) assert response is None + @staticmethod + def test_that_stronghold_allowed_urls_are_allowed_for_normal_user( + middleware, kwargs): + web_request = RequestFactory().get('/o/authorize/') + web_request.user = Mock() + web_request.user.groups.filter().exists = Mock(return_value=False) + web_request.session = MagicMock() + response = middleware.process_view(web_request, **kwargs) + assert response is None + class TestCommonErrorMiddleware: """Test cases for common error middleware.""" diff --git a/plinth/urls.py b/plinth/urls.py index 8842e7b88..59bc844d8 100644 --- a/plinth/urls.py +++ b/plinth/urls.py @@ -3,7 +3,8 @@ Django URLconf file containing all urls """ from captcha import views as cviews -from django.urls import include, re_path +from django.urls import include, path, re_path +from oauth2_provider import urls as oauth2_urls from stronghold.decorators import public from . import views @@ -46,5 +47,9 @@ urlpatterns = [ # Notifications re_path(r'^notification/(?P[A-Za-z0-9-=]+)/dismiss/$', - views.notification_dismiss, name='notification_dismiss') + views.notification_dismiss, name='notification_dismiss'), + + # OpenID Provider related URLs. They need to be under 'oauth2_provider:' + # namespace otherwise .well-known/openid-configuration will fail. + path('o/', include(oauth2_urls)), ] diff --git a/pyproject.toml b/pyproject.toml index e04778dbf..436ab5e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,6 +170,7 @@ module = [ "django.*", "gi.*", "numpy.*", + "oauth2_provider.*", "pam.*", "pexpect.*", "pgi.*",