mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-03-11 09:04:54 +00:00
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 <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
f0b1aa34ac
commit
45076cc603
@ -76,6 +76,18 @@
|
||||
RequestHeader set X-Forwarded-Proto 'https' env=HTTPS
|
||||
RequestHeader unset X-Forwarded-For
|
||||
</Location>
|
||||
<Location /.well-known/openid-configuration>
|
||||
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
|
||||
</Location>
|
||||
<Location /.well-known/jwks.json>
|
||||
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
|
||||
</Location>
|
||||
|
||||
##
|
||||
## Serve FreedomBox icon as /favicon.ico for apps that don't present their own
|
||||
|
||||
2
debian/control
vendored
2
debian/control
vendored
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
79
plinth/modules/oidc/__init__.py
Normal file
79
plinth/modules/oidc/__init__.py
Normal file
@ -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()
|
||||
169
plinth/modules/oidc/components.py
Normal file
169
plinth/modules/oidc/components.py
Normal file
@ -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)
|
||||
@ -0,0 +1 @@
|
||||
plinth.modules.oidc
|
||||
0
plinth/modules/oidc/tests/__init__.py
Normal file
0
plinth/modules/oidc/tests/__init__.py
Normal file
104
plinth/modules/oidc/tests/test_components.py
Normal file
104
plinth/modules/oidc/tests/test_components.py
Normal file
@ -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
|
||||
31
plinth/modules/oidc/urls.py
Normal file
31
plinth/modules/oidc/urls.py
Normal file
@ -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 = []
|
||||
124
plinth/modules/oidc/validators.py
Normal file
124
plinth/modules/oidc/validators.py
Normal file
@ -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.
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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<id>[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)),
|
||||
]
|
||||
|
||||
@ -170,6 +170,7 @@ module = [
|
||||
"django.*",
|
||||
"gi.*",
|
||||
"numpy.*",
|
||||
"oauth2_provider.*",
|
||||
"pam.*",
|
||||
"pexpect.*",
|
||||
"pgi.*",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user