diff --git a/docs/install.md b/docs/install.md index 999cf16..3e8b587 100644 --- a/docs/install.md +++ b/docs/install.md @@ -114,7 +114,8 @@ Listen 8000 ``` # Django Authentication -This portal is using the standard django authentication, multiple backends are supported, we are using SAML2 and the FreeIPA backend, on different clusters. Users with the is_staff attribute can access other users pages and the `top` module. +This portal is using standard Django authentication. Multiple backends are supported; we are using SAML2, OIDC/OAuth, and the FreeIPA/LDAP backend on different clusters. Users with the is_staff attribute can access other users' pages and the `top` module. + ## SAML2 For SAML2, certificates need to be generated and the metadata.xml need a little modification. diff --git a/docs/oauth2jwt.md b/docs/oauth2jwt.md new file mode 100644 index 0000000..30bcff0 --- /dev/null +++ b/docs/oauth2jwt.md @@ -0,0 +1,59 @@ +# Generic OAuth2 + JWT Authentication + +This backend allows users to authenticate against any Identity Provider using OAuth 2.0 and JWT tokens. + +## How it Works + +1. **Authorization Redirect**: When a user goes to `/oauth2/login/`, they are redirected to the Identity Provider authorization endpoint. +2. **Authorization Code Grant**: After successful login at the Identity Provider, the user is redirected back to the callback view (`/oauth2/callback/`) with an authorization code. +3. **JWT Access Token Exchange**: The callback view sends a POST request to the Provider token endpoint to exchange the authorization code for a JWT access token. +4. **Token Decoding**: The custom `staffOAuth2JWTBackend` decodes the JWT payload. + - By default, it decodes with `verify_signature=False`. + - Alternatively, it supports signature verification (like RS256) if `JWT_OAUTH2_VERIFY_SIGNATURE` is set to `True`. +5. **User Mapping**: The backend updates user fields and roles in `configure_user`: + - Synchronizes first name, last name, and email based on claims list. + - Determines `is_staff` using `JWT_OAUTH2_STAFF_ATTRIBUTES`. + - Determines `is_superuser` if the `superuser` claim is `True`. + - Checks access restrictions via `JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES`. + +## Configuration + +To enable generic OAuth2 + JWT authentication: + +1. **Register the OAuth Application in your Identity Provider** and retrieve the **Client ID** and **Client Secret**. Configure the Redirect URI: + - **Redirect URI**: `https:///oauth2/callback/` + +2. **Enable the application and backend** in `userportal/settings/43-oauth2jwt.py` by uncommenting the setup lines: + + ```python + JWT_OAUTH2_AUTH_ENABLED = True + AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOAuth2JWTBackend'] + AUTHENTICATION_BACKENDS + LOGIN_URL = '/oauth2/login/' + ``` + +3. **Configure your Credentials and Endpoints** in `userportal/settings/43-oauth2jwt.py`: + + ```python + JWT_OAUTH2_PROVIDER_URL = 'https://idp.example.com' + JWT_OAUTH2_CLIENT_ID = 'your-client-id' + JWT_OAUTH2_CLIENT_SECRET = 'your-client-secret' + ``` + +You can also specify `JWT_OAUTH2_AUTHORIZATION_URL` and `JWT_OAUTH2_TOKEN_URL` explicitly, which by default have the value of `JWT_OAUTH2_PROVIDER_URL/oauth2/authorize/` and `JWT_OAUTH2_PROVIDER_URL/oauth2/access_token/` respectively. + +If needed, you can specify extra token parameters with `JWT_OAUTH2_EXTRA_TOKEN_PARAMS`. This allows to pass additional information to the OAUTH2 endpoint. For OpenEdX, define + ```python + JWT_OAUTH2_EXTRA_TOKEN_PARAMS = {'token_type': 'jwt'} + ``` + +4. **Map access and staff roles** based on the claims structure: + + ```python + # Only allow users with specific claims to log in + JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES = [] + + # Automatically set user.is_staff based on claim values + JWT_OAUTH2_STAFF_ATTRIBUTES = [ + ('administrator', True) + ] + ``` diff --git a/docs/oidc.md b/docs/oidc.md new file mode 100644 index 0000000..1ce09a0 --- /dev/null +++ b/docs/oidc.md @@ -0,0 +1,56 @@ +# OpenID Connect (OIDC) / OAuth Authentication + +`mozilla-django-oidc` is supported as a lightweight OpenID Connect (OIDC) and OAuth 2.0 client authentication backend, similar to SAML2. + +## How it Works + +OIDC enables user authentication via an external Identity Provider (IdP) supporting OpenID Connect (such as Keycloak, Okta, Authentik, Dex, etc.). + +When a user tries to access a protected page, they are redirected to the OIDC provider's login page. Upon successful authentication, they are redirected back to the portal with an authorization code. The portal exchanges this code for an ID token and access token, and queries the userinfo endpoint to fetch user details (such as first name, last name, and role/group membership claims). + +The custom `staffOIDCBackend` then: +- Validates the tokens and claims. +- Generates a local username from the configured claim (e.g., `preferred_username`), cleaning the domain part if the claim is formatted as an email or principal name (e.g. `john@example.com` becomes `john`). +- Synchronizes the user's first and last names. +- Determines whether the user is active using `OIDC_REQUIRED_ACCESS_ATTRIBUTES`. +- Determines whether the user is a staff member using `OIDC_STAFF_ATTRIBUTES`. + +## Configuration + +To enable OIDC/OAuth authentication: + +1. **Enable the application and backend** in `userportal/settings/42-oidc.py` by uncommenting the setup lines: + + ```python + INSTALLED_APPS += ['mozilla_django_oidc'] + AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOIDCBackend'] + AUTHENTICATION_BACKENDS + LOGIN_URL = '/oidc/authenticate/' + ``` + +2. **Configure your OIDC Client Credentials and Endpoints** in `userportal/settings/42-oidc.py` (or a local overrides file like `99-local.py`): + + ```python + OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' + OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' + OIDC_OP_USERINFO_ENDPOINT = 'https://your-idp.example.com/userinfo' + OIDC_OP_JWKS_ENDPOINT = 'https://your-idp.example.com/jwks' + + OIDC_RP_CLIENT_ID = 'your-client-id' + OIDC_RP_CLIENT_SECRET = 'your-client-secret' + ``` + +3. **Map access and staff roles** based on the returned OIDC claims: + + ```python + # Only allow users with specific claims to log in + OIDC_REQUIRED_ACCESS_ATTRIBUTES = [ + ('email_verified', True) + ] + + # Automatically set user.is_staff based on OIDC claim values (e.g. group membership) + OIDC_STAFF_ATTRIBUTES = [ + ('groups', 'portal-staff') + ] + ``` + +For more details on available configurations, refer to the [mozilla-django-oidc documentation](https://mozilla-django-oidc.readthedocs.io/). diff --git a/mkdocs.yml b/mkdocs.yml index 684a89f..8c00cfd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,9 @@ nav: - 'Quotas': quotas.md - 'Quotas GPFS': quotasgpfs.md - 'CF Access': cfaccess.md + - 'OIDC / OAuth': oidc.md + - 'OAuth2 + JWT': oauth2jwt.md + theme: name: material diff --git a/multipleproxy/middleware.py b/multipleproxy/middleware.py new file mode 100644 index 0000000..aba313c --- /dev/null +++ b/multipleproxy/middleware.py @@ -0,0 +1,21 @@ +class MultipleProxyMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + FORWARDED_FOR_FIELDS = [ + 'HTTP_X_FORWARDED_FOR', + 'HTTP_X_FORWARDED_HOST', + 'HTTP_X_FORWARDED_SERVER', + ] + + def __call__(self, request): + """ + Rewrites the proxy headers so that only the most + recent proxy is used. + """ + for field in self.FORWARDED_FOR_FIELDS: + if field in request.META: + if "," in request.META[field]: + parts = request.META[field].split(",") + request.META[field] = parts[-1].strip() + return self.get_response(request) diff --git a/requirements.txt b/requirements.txt index 22b764f..0050065 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,3 +45,5 @@ tzlocal==5.2 urllib3==2.6.3 xmlschema==2.5.1 PyJWT==2.12.1 +mozilla-django-oidc==5.0.2 + diff --git a/tests/tests.py b/tests/tests.py index a4f5f7a..a94dddd 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,7 +1,8 @@ -from django.test import TestCase +from django.test import Client, override_settings, TestCase from django.contrib.auth import get_user_model from django.conf import settings -from django.test import Client +from django.urls import reverse +from unittest.mock import patch, MagicMock class CustomTestCase(TestCase): @@ -24,3 +25,71 @@ def setUp(self): def assertJSONKeys(self, response, keys): self.assertEqual(set(response.json().keys()), set(keys)) + + +class OAuth2JWTAuthTestCase(TestCase): + databases = '__all__' + + @override_settings( + JWT_OAUTH2_AUTH_ENABLED=True, + JWT_OAUTH2_PROVIDER_URL='https://idp.example.com', + JWT_OAUTH2_CLIENT_ID='test-client-id', + JWT_OAUTH2_CLIENT_SECRET='test-client-secret', + JWT_OAUTH2_VERIFY_SIGNATURE=False, + ) + def test_login_redirect(self): + client = Client() + response = client.get(reverse('oauth2_jwt_login')) + self.assertEqual(response.status_code, 302) + self.assertTrue(response['Location'].startswith('https://idp.example.com/oauth2/authorize/')) + self.assertIn('client_id=test-client-id', response['Location']) + self.assertIn('response_type=code', response['Location']) + + @override_settings( + JWT_OAUTH2_AUTH_ENABLED=True, + JWT_OAUTH2_PROVIDER_URL='https://idp.example.com', + JWT_OAUTH2_CLIENT_ID='test-client-id', + JWT_OAUTH2_CLIENT_SECRET='test-client-secret', + JWT_OAUTH2_VERIFY_SIGNATURE=False, + JWT_OAUTH2_STAFF_ATTRIBUTES=[('administrator', True)], + AUTHENTICATION_BACKENDS=['userportal.authentication.staffOAuth2JWTBackend'] + settings.AUTHENTICATION_BACKENDS, + ) + @patch('requests.post') + @patch('jwt.decode') + def test_callback_success(self, mock_jwt_decode, mock_post): + # Mock requests.post for token exchange + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'access_token': 'fake-jwt-token'} + mock_post.return_value = mock_response + + # Mock jwt.decode for payload extraction + mock_jwt_decode.return_value = { + 'username': 'oauth2user', + 'email': 'oauth2user@example.com', + 'given_name': 'OAuth2', + 'family_name': 'User', + 'administrator': True, + 'superuser': False, + } + + # Set session state + client = Client() + session = client.session + session['oauth2_jwt_state'] = 'teststate' + session.save() + + response = client.get(reverse('oauth2_jwt_callback'), {'state': 'teststate', 'code': 'testcode'}) + + # Verify redirect to home + self.assertEqual(response.status_code, 302) + self.assertEqual(response['Location'], '/') + + # Verify user was created + User = get_user_model() + user = User.objects.get(username='oauth2user') + self.assertEqual(user.email, 'oauth2user@example.com') + self.assertEqual(user.first_name, 'OAuth2') + self.assertEqual(user.last_name, 'User') + self.assertTrue(user.is_staff) + self.assertFalse(user.is_superuser) diff --git a/userportal/authentication.py b/userportal/authentication.py index 9bc37cb..4dc1e02 100644 --- a/userportal/authentication.py +++ b/userportal/authentication.py @@ -1,19 +1,33 @@ -from djangosaml2.backends import Saml2Backend -from django.contrib.auth.backends import RemoteUserBackend +from django.contrib.auth.backends import ModelBackend, RemoteUserBackend +from django.conf import settings +try: + from djangosaml2.backends import Saml2Backend -class staffSaml2Backend(Saml2Backend): - """This will add/remove the is_staff attribute from the user as appropriate.""" - def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): - if 'eduPersonAffiliation' in attributes: - if 'staff' in attributes['eduPersonAffiliation']: - user.is_staff = True - else: - user.is_staff = False - user.first_name = attributes['givenName'][0] - user.last_name = attributes['sn'][0] - force_save = True - return super()._update_user(user, attributes, attribute_mapping, force_save) + class staffSaml2Backend(Saml2Backend): + """This will add/remove the is_staff attribute from the user as appropriate.""" + def _update_user(self, user, attributes: dict, attribute_mapping: dict, force_save: bool = False): + # figure out if user is active (i.e. can login) + user.is_active = True + for attribute, value in settings.SAML_CONFIG['required_access_attributes']: + if attribute not in attributes or value not in attributes[attribute]: + user.is_active = False + break + + # figure out if user is staff + user.is_staff = False + for attribute, value in settings.SAML_CONFIG['staff_attributes']: + if attribute in attributes and value in attributes[attribute]: + user.is_staff = True + break + + user.first_name = attributes['givenName'][0] + user.last_name = attributes['sn'][0] + force_save = True + return super()._update_user(user, attributes, attribute_mapping, force_save) + +except ImportError: + pass class staffRemoteUserBackend(RemoteUserBackend): @@ -32,3 +46,267 @@ def configure_user(self, request, user, created=True): user.is_staff = False user.save() return user + + +try: + from django_auth_ldap.backend import LDAPBackend + + class staffLdapBackend(LDAPBackend): + def get_or_build_user(self, username, ldap_user): + user, built = super().get_or_build_user(username, ldap_user) + + # figure out if user is active (i.e. can login) + user.is_active = True + for attribute, value in settings.LDAP_CONFIG['required_access_attributes']: + if attribute not in ldap_user.attrs.data or value not in ldap_user.attrs.data[attribute]: + user.is_active = False + break + + user.is_staff = False + for attribute, value in settings.LDAP_CONFIG['staff_attributes']: + if attribute in ldap_user.attrs.data and value in ldap_user.attrs.data[attribute]: + user.is_staff = True + + return user, built + + +except ImportError: + pass + +try: + from mozilla_django_oidc.auth import OIDCAuthenticationBackend + + class staffOIDCBackend(OIDCAuthenticationBackend): + """Claims verifications is done in _update_user_attributes""" + def verify_claims(self, claims): + return True + + """Get users that match by username""" + def filter_users_by_claims(self, claims): + username = self.get_username(claims) + return self.UserModel.objects.filter(username__iexact=username) + + """This will add/remove the is_staff and is_active attributes from the user as appropriate based on OIDC claims.""" + def get_username(self, claims): + username = claims.get('preferred_username') + if not username: + username = claims.get('sub') + + if username and '@' in username: + username = username.split('@')[0] + return username + + def _update_user_attributes(self, user, claims): + user.is_staff = False + staff_attrs = getattr(settings, 'OIDC_STAFF_ATTRIBUTES', [('groups', 'staff')]) + for attribute, value in staff_attrs: + claim_val = claims.get(attribute) + if claim_val: + if isinstance(claim_val, list): + if value in claim_val: + user.is_staff = True + break + else: + if str(value).lower() == str(claim_val).lower(): + user.is_staff = True + break + + user.is_active = True + req_attrs = getattr(settings, 'OIDC_REQUIRED_ACCESS_ATTRIBUTES', []) + for attribute, value in req_attrs: + claim_val = claims.get(attribute) + if not claim_val: + user.is_active = False + break + if isinstance(claim_val, list): + if value not in claim_val: + user.is_active = False + break + else: + if str(value).lower() != str(claim_val).lower(): + user.is_active = False + break + + user.first_name = claims.get('given_name', claims.get('first_name', '')) + user.last_name = claims.get('family_name', claims.get('last_name', '')) + user.save() + return user + + def create_user(self, claims): + user = super().create_user(claims) + return self._update_user_attributes(user, claims) + + def update_user(self, user, claims): + user = super().update_user(user, claims) + return self._update_user_attributes(user, claims) +except ImportError: + pass + + +class staffOAuth2JWTBackend(ModelBackend): + """Authentication backend for generic OAuth2 + JWT tokens.""" + + @property + def UserModel(self): + from django.contrib.auth import get_user_model + return get_user_model() + + @property + def create_unknown_user(self): + return getattr(settings, 'JWT_OAUTH2_CREATE_UNKNOWN_USER', True) + + def verify_token(self, token): + import jwt + try: + verify_signature = getattr(settings, 'JWT_OAUTH2_VERIFY_SIGNATURE', False) + if verify_signature: + from jwt import PyJWKClient + jwks_endpoint = getattr(settings, 'JWT_OAUTH2_JWKS_ENDPOINT', None) + algorithms = getattr(settings, 'JWT_OAUTH2_SIGN_ALGOS', [getattr(settings, 'JWT_OAUTH2_SIGN_ALGO', 'RS256')]) + audience = getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None) + + if jwks_endpoint and 'RS256' in algorithms: + jwk_client = PyJWKClient(jwks_endpoint) + signing_key = jwk_client.get_signing_key_from_jwt(token) + key = signing_key.key + else: + key = getattr(settings, 'JWT_OAUTH2_SECRET_KEY', getattr(settings, 'JWT_OAUTH2_CLIENT_SECRET', None)) + + if not key: + raise ValueError("No signature verification key or JWKS endpoint configured.") + + payload = jwt.decode( + token, + key, + algorithms=algorithms, + audience=audience, + options={"verify_aud": bool(audience)} + ) + else: + # Decode user info without verification + payload = jwt.decode( + token, + options={"verify_signature": False} + ) + return payload + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to decode/verify JWT token: {e}") + return None + + def verify_claims(self, claims): + req_attrs = getattr(settings, 'JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES', []) + for attribute, value in req_attrs: + claim_val = claims.get(attribute) + if claim_val is None: + return False + if isinstance(claim_val, list): + if value not in claim_val: + return False + else: + if str(value).lower() != str(claim_val).lower(): + return False + return True + + def clean_username(self, username): + if username and '@' in username: + username = username.split('@')[0] + return username + + def configure_user(self, request, user, created=True): + """Configure user based on the decoded claims stored in request or backend.""" + claims = getattr(request, '_oauth2_jwt_claims', getattr(self, '_temp_claims', None)) + if not claims: + return user + + # Set first_name + first_name_claims = getattr(settings, 'JWT_OAUTH2_FIRST_NAME_CLAIMS', ['given_name', 'first_name', 'name']) + for claim in first_name_claims: + val = claims.get(claim) + if val: + user.first_name = val + break + + # Set last_name + last_name_claims = getattr(settings, 'JWT_OAUTH2_LAST_NAME_CLAIMS', ['family_name', 'last_name']) + for claim in last_name_claims: + val = claims.get(claim) + if val: + user.last_name = val + break + + # Set email + email_claim = getattr(settings, 'JWT_OAUTH2_EMAIL_CLAIM', 'email') + user.email = claims.get(email_claim, '') + + # Set staff status + user.is_staff = False + staff_attrs = getattr(settings, 'JWT_OAUTH2_STAFF_ATTRIBUTES', [('administrator', True)]) + for attribute, value in staff_attrs: + claim_val = claims.get(attribute) + if claim_val is not None: + if isinstance(claim_val, list): + if value in claim_val: + user.is_staff = True + break + else: + if str(value).lower() == str(claim_val).lower(): + user.is_staff = True + break + + # Set superuser status + if claims.get('superuser') is True: + user.is_superuser = True + + user.save() + return user + + def authenticate(self, request, remote_user=None, token=None, **kwargs): + """ + Authenticate with JWT token. + """ + if token: + claims = self.verify_token(token) + if not claims: + return None + if not self.verify_claims(claims): + return None + + username = None + username_claims = getattr(settings, 'JWT_OAUTH2_USERNAME_CLAIMS', ['preferred_username', 'username', 'sub']) + for claim in username_claims: + username = claims.get(claim) + if username: + break + + if not username: + return None + + # Store claims temporarily where configure_user can access them + self._temp_claims = claims + if request: + request._oauth2_jwt_claims = claims + + # Find or create user + username = self.clean_username(username) + if self.create_unknown_user: + user, created = self.UserModel._default_manager.get_or_create(**{ + self.UserModel.USERNAME_FIELD: username + }) + user = self.configure_user(request, user, created=created) + else: + try: + user = self.UserModel._default_manager.get_by_natural_key(username) + user = self.configure_user(request, user, created=False) + except self.UserModel.DoesNotExist: + user = None + + self._temp_claims = None + if request and hasattr(request, '_oauth2_jwt_claims'): + delattr(request, '_oauth2_jwt_claims') + + if user and self.user_can_authenticate(user): + return user + + return None diff --git a/userportal/settings/10-base.py b/userportal/settings/10-base.py index 8eab4bc..e56a61b 100644 --- a/userportal/settings/10-base.py +++ b/userportal/settings/10-base.py @@ -152,6 +152,8 @@ LDAP_BASE_DN = 'dc=computecanada,dc=ca' +LDAP_CONFIG = {} + LOGIN_REDIRECT_URL = '/' # Set to DEMO to True to enable demo mode with anonymized data diff --git a/userportal/settings/40-saml.py b/userportal/settings/40-saml.py index f55f36b..f566d97 100644 --- a/userportal/settings/40-saml.py +++ b/userportal/settings/40-saml.py @@ -64,4 +64,11 @@ 'key_file': '/opt/private.key', # private part 'cert_file': '/opt/public.cert', # public part }], -} \ No newline at end of file + # Use this to define if the user can login + 'required_access_attributes': [], + + # Use this to assign the staff role based on attributes returned by SAML + 'staff_attributes': [ + ('eduPersonAffiliation', 'staff') + ] +} diff --git a/userportal/settings/42-oidc.py b/userportal/settings/42-oidc.py new file mode 100644 index 0000000..dcbbc1b --- /dev/null +++ b/userportal/settings/42-oidc.py @@ -0,0 +1,49 @@ +# OIDC Settings using mozilla-django-oidc +# For documentation, see docs/oidc.md + +# To enable OIDC authentication, uncomment these lines: +# INSTALLED_APPS += ['mozilla_django_oidc'] +# MIDDLEWARE += ['mozilla_django_oidc.middleware.SessionRefresh'] # Optional, for token expiration/session refresh +# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOIDCBackend'] + AUTHENTICATION_BACKENDS +# LOGIN_URL = '/oidc/authenticate/' +# LOGIN_REDIRECT_URL = '/' +# LOGOUT_REDIRECT_URL = '/' + +# if behind a proxy +# USE_X_FORWARDED_HOST = True +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# for the case of multiple proxies, this is needed +# MIDDLEWARE = ['multipleproxy.middleware.MultipleProxyMiddleware'] + MIDDLEWARE + + +# OpenID Connect Provider configurations: +OIDC_OP_AUTHORIZATION_ENDPOINT = 'https://your-idp.example.com/auth' +OIDC_OP_TOKEN_ENDPOINT = 'https://your-idp.example.com/token' +OIDC_OP_USER_ENDPOINT = 'https://your-idp.example.com/userinfo' +OIDC_OP_JWKS_ENDPOINT = 'https://your-idp.example.com/jwks' + +OIDC_RP_CLIENT_ID = 'your-client-id' +OIDC_RP_CLIENT_SECRET = 'your-client-secret' + +# Algorithm for verifying JWT signatures (e.g. RS256, HS256) +OIDC_RP_SIGN_ALGO = 'RS256' + +# Custom scopes if needed +OIDC_RP_SCOPES = 'openid email profile' + +# If set to True, a new Django user will be created if one does not exist +OIDC_CREATE_USER = True + +# Username claim to use for matching the LDAP username. +# Our custom staffOIDCBackend cleans the domain part if the claim contains an email or full principal name. +OIDC_USERNAME_CLAIM = 'preferred_username' + +# Use this to define if the user can login. +# List of tuples of (claim_name, expected_value). All must match. +OIDC_REQUIRED_ACCESS_ATTRIBUTES = [] + +# Use this to assign the staff role based on claims returned by OIDC. +# List of tuples of (claim_name, expected_value). If ANY matches, the user is_staff will be set to True. +OIDC_STAFF_ATTRIBUTES = [ + ('groups', 'staff') +] diff --git a/userportal/settings/43-oauth2jwt.py b/userportal/settings/43-oauth2jwt.py new file mode 100644 index 0000000..b885d30 --- /dev/null +++ b/userportal/settings/43-oauth2jwt.py @@ -0,0 +1,33 @@ +# Generic OAuth2 + JWT Settings +# For documentation, see docs/oauth2jwt.md + +# To enable generic OAuth2 + JWT authentication, uncomment these lines: +# JWT_OAUTH2_AUTH_ENABLED = True +# AUTHENTICATION_BACKENDS = ['userportal.authentication.staffOAuth2JWTBackend'] + AUTHENTICATION_BACKENDS +# LOGIN_URL = '/oauth2/login/' +# LOGIN_REDIRECT_URL = '/' +# LOGOUT_REDIRECT_URL = '/' + +# Provider configuration: +JWT_OAUTH2_PROVIDER_URL = 'https://idp.example.com' +JWT_OAUTH2_CLIENT_ID = 'your-client-id' +JWT_OAUTH2_CLIENT_SECRET = 'your-client-secret' + +# JWT signature verification options +# By default, verify_signature can be False when using server-to-server token exchange +JWT_OAUTH2_VERIFY_SIGNATURE = False +JWT_OAUTH2_SIGN_ALGO = 'RS256' + +# Scope to request from the Identity Provider +JWT_OAUTH2_SCOPE = 'read write email profile' + +# Create Django user if they don't exist +JWT_OAUTH2_CREATE_UNKNOWN_USER = True + +# List of tuples of (claim_name, expected_value) for access requirements (must all match) +JWT_OAUTH2_REQUIRED_ACCESS_ATTRIBUTES = [] + +# List of tuples of (claim_name, expected_value). If ANY matches, user.is_staff will be True. +JWT_OAUTH2_STAFF_ATTRIBUTES = [ + ('administrator', True) +] diff --git a/userportal/urls.py b/userportal/urls.py index 090e5e3..bad678d 100644 --- a/userportal/urls.py +++ b/userportal/urls.py @@ -65,6 +65,18 @@ path('test/', djangosaml2.views.EchoAttributesView.as_view()), ] +if "mozilla_django_oidc" in settings.INSTALLED_APPS: + urlpatterns += [ + path('oidc/', include('mozilla_django_oidc.urls')), + ] + +if getattr(settings, 'JWT_OAUTH2_AUTH_ENABLED', False): + from userportal.views import OAuth2JWTLoginView, OAuth2JWTCallbackView + urlpatterns += [ + path('oauth2/login/', OAuth2JWTLoginView.as_view(), name='oauth2_jwt_login'), + path('oauth2/callback/', OAuth2JWTCallbackView.as_view(), name='oauth2_jwt_callback'), + ] + if 'jobstats' in settings.INSTALLED_APPS: urlpatterns.append(path('secure/jobstats/', include('jobstats.urls'))) diff --git a/userportal/views.py b/userportal/views.py new file mode 100644 index 0000000..dbbb7dd --- /dev/null +++ b/userportal/views.py @@ -0,0 +1,101 @@ +import logging +import secrets +from django.conf import settings +from django.contrib.auth import authenticate, login +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.urls import reverse +from django.views import View +import requests + +logger = logging.getLogger(__name__) + + +class OAuth2JWTLoginView(View): + """View to redirect users to OAuth2 Identity Provider for authorization.""" + + def get(self, request, *args, **kwargs): + # Generate a random state to prevent CSRF + state = secrets.token_urlsafe(32) + request.session['oauth2_jwt_state'] = state + + auth_url = getattr(settings, 'JWT_OAUTH2_AUTHORIZATION_URL', None) + if not auth_url: + provider_url = getattr(settings, 'JWT_OAUTH2_PROVIDER_URL', None) + if provider_url: + auth_url = f"{provider_url.rstrip('/')}/oauth2/authorize/" + + if not auth_url: + return HttpResponseBadRequest("Authorization URL is not configured.") + + # Build authorization redirect URL + redirect_uri = request.build_absolute_uri(reverse('oauth2_jwt_callback')) + + params = { + 'response_type': 'code', + 'client_id': getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None), + 'redirect_uri': redirect_uri, + 'scope': getattr(settings, 'JWT_OAUTH2_SCOPE', 'read write email profile'), + 'state': state, + } + + url_with_params = f"{auth_url}?{requests.compat.urlencode(params)}" + return HttpResponseRedirect(url_with_params) + + +class OAuth2JWTCallbackView(View): + """View to handle OAuth2 callback and authenticate the user using JWT token.""" + + def get(self, request, *args, **kwargs): + # Validate state + state = request.GET.get('state') + session_state = request.session.pop('oauth2_jwt_state', None) + if not state or state != session_state: + return HttpResponseBadRequest("Invalid state parameter.") + + code = request.GET.get('code') + if not code: + return HttpResponseBadRequest("Missing authorization code.") + + token_url = getattr(settings, 'JWT_OAUTH2_TOKEN_URL', None) + if not token_url: + provider_url = getattr(settings, 'JWT_OAUTH2_PROVIDER_URL', None) + if provider_url: + token_url = f"{provider_url.rstrip('/')}/oauth2/access_token/" + + if not token_url: + return HttpResponseBadRequest("Token URL is not configured.") + + redirect_uri = request.build_absolute_uri(reverse('oauth2_jwt_callback')) + + # Exchange authorization code for access token + data = { + 'grant_type': 'authorization_code', + 'client_id': getattr(settings, 'JWT_OAUTH2_CLIENT_ID', None), + 'client_secret': getattr(settings, 'JWT_OAUTH2_CLIENT_SECRET', None), + 'code': code, + 'redirect_uri': redirect_uri, + } + + extra_params = getattr(settings, 'JWT_OAUTH2_EXTRA_TOKEN_PARAMS', {}) + data.update(extra_params) + + try: + response = requests.post(token_url, data=data, timeout=10) + response.raise_for_status() + res_data = response.json() + except Exception as e: + logger.error(f"Failed to exchange code for OAuth2 token: {e}") + return HttpResponseBadRequest("Token exchange failed.") + + access_token = res_data.get('access_token') + if not access_token: + return HttpResponseBadRequest("No access token returned from Identity Provider.") + + # Authenticate user with the token using the custom backend + user = authenticate(request, token=access_token) + if user is not None: + login(request, user) + redirect_url = getattr(settings, 'LOGIN_REDIRECT_URL', '/') + return HttpResponseRedirect(redirect_url) + else: + return HttpResponseBadRequest("Authentication failed.")