diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e69d47..197d6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(oidc) people as an identity provider #638 + ### Fixed - 🧱(helm) add resource-server ingress path #743 diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 1204fd7..30e1720 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -60,7 +60,17 @@ class UserAdmin(auth_admin.UserAdmin): ) }, ), - (_("Personal info"), {"fields": ("name", "email", "language", "timezone")}), + ( + _("Personal info"), + { + "fields": ( + "name", + "email", + "language", + "timezone", + ) + }, + ), ( _("Permissions"), { diff --git a/src/backend/core/authentication/urls.py b/src/backend/core/authentication/urls.py index 2a66c83..a8b44cf 100644 --- a/src/backend/core/authentication/urls.py +++ b/src/backend/core/authentication/urls.py @@ -2,7 +2,7 @@ from django.urls import path -from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls +from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls from .views import OIDCLogoutCallbackView, OIDCLogoutView @@ -14,5 +14,5 @@ urlpatterns = [ OIDCLogoutCallbackView.as_view(), name="oidc_logout_callback", ), - *mozzila_oidc_urls, + *mozilla_oidc_urls, ] diff --git a/src/backend/mailbox_manager/admin.py b/src/backend/mailbox_manager/admin.py index 8f39170..a862a4c 100644 --- a/src/backend/mailbox_manager/admin.py +++ b/src/backend/mailbox_manager/admin.py @@ -1,6 +1,7 @@ """Admin classes and registrations for People's mailbox manager app.""" from django.contrib import admin, messages +from django.contrib.auth.admin import UserAdmin from django.utils.html import format_html_join, mark_safe from django.utils.translation import gettext_lazy as _ @@ -163,7 +164,7 @@ class MailDomainAdmin(admin.ModelAdmin): @admin.register(models.Mailbox) -class MailboxAdmin(admin.ModelAdmin): +class MailboxAdmin(UserAdmin): """Admin for mailbox model.""" list_display = ("__str__", "domain", "status", "updated_at") @@ -171,6 +172,29 @@ class MailboxAdmin(admin.ModelAdmin): search_fields = ("local_part", "domain__name") readonly_fields = ["updated_at", "local_part", "domain"] + fieldsets = None + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "first_name", + "last_name", + "local_part", + "domain", + "secondary_email", + "status", + "usable_password", + "password1", + "password2", + ), + }, + ), + ) + ordering = ("local_part", "domain") + filter_horizontal = () + @admin.register(models.MailDomainAccess) class MailDomainAccessAdmin(admin.ModelAdmin): diff --git a/src/backend/mailbox_manager/api/client/serializers.py b/src/backend/mailbox_manager/api/client/serializers.py index e6882bb..51b1d28 100644 --- a/src/backend/mailbox_manager/api/client/serializers.py +++ b/src/backend/mailbox_manager/api/client/serializers.py @@ -2,6 +2,8 @@ from logging import getLogger +from django.contrib.auth.hashers import make_password + from requests.exceptions import HTTPError from rest_framework import exceptions, serializers @@ -33,8 +35,16 @@ class MailboxSerializer(serializers.ModelSerializer): def create(self, validated_data): """ Override create function to fire a request on mailbox creation. + + By default, we generate an unusable password for the mailbox, meaning that the mailbox + will not be able to be used as a login credential until the password is set. """ - mailbox = super().create(validated_data) + mailbox = super().create( + validated_data + | { + "password": make_password(None), # generate an unusable password + } + ) if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED: client = DimailAPIClient() # send new mailbox request to dimail diff --git a/src/backend/mailbox_manager/factories.py b/src/backend/mailbox_manager/factories.py index d04bd28..acf9919 100644 --- a/src/backend/mailbox_manager/factories.py +++ b/src/backend/mailbox_manager/factories.py @@ -2,6 +2,7 @@ Mailbox manager application factories """ +from django.contrib.auth.hashers import make_password from django.utils.text import slugify import factory.fuzzy @@ -76,6 +77,7 @@ class MailboxFactory(factory.django.DjangoModelFactory): ) domain = factory.SubFactory(MailDomainEnabledFactory) secondary_email = factory.Faker("email") + password = make_password("password") class MailboxEnabledFactory(MailboxFactory): diff --git a/src/backend/mailbox_manager/migrations/0018_maildomain_organization.py b/src/backend/mailbox_manager/migrations/0018_maildomain_organization.py deleted file mode 100644 index ef668a6..0000000 --- a/src/backend/mailbox_manager/migrations/0018_maildomain_organization.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.5 on 2025-02-10 11:10 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0010_team_depth_team_numchild_team_path_and_more'), - ('mailbox_manager', '0017_alter_maildomain_status'), - ] - - operations = [ - migrations.AddField( - model_name='maildomain', - name='organization', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='mail_domains', to='core.organization'), - ), - ] diff --git a/src/backend/mailbox_manager/migrations/0023_mailbox_email_mailbox_last_login_mailbox_password.py b/src/backend/mailbox_manager/migrations/0023_mailbox_email_mailbox_last_login_mailbox_password.py index 9f8c605..8c0faf7 100644 --- a/src/backend/mailbox_manager/migrations/0023_mailbox_email_mailbox_last_login_mailbox_password.py +++ b/src/backend/mailbox_manager/migrations/0023_mailbox_email_mailbox_last_login_mailbox_password.py @@ -19,7 +19,7 @@ def fill_dn_email(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('mailbox_manager', '0020_maildomain_organization'), + ('mailbox_manager', '0022_maildomain_organization'), ] operations = [ diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 6d58b9e..2f082ba 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -3,6 +3,7 @@ Declare and configure the models for the People additional application : mailbox """ from django.conf import settings +from django.contrib.auth.base_user import AbstractBaseUser from django.core import exceptions, validators from django.db import models from django.utils.text import slugify @@ -94,6 +95,14 @@ class MailDomain(BaseModel): "manage_accesses": is_owner_or_admin, } + def is_identity_provider_ready(self) -> bool: + """ + Check if the identity provider is ready to manage the domain. + """ + return ( + bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED + ) + class MailDomainAccess(BaseModel): """Allow to manage users' accesses to mail domains.""" @@ -188,7 +197,7 @@ class MailDomainAccess(BaseModel): } -class Mailbox(BaseModel): +class Mailbox(AbstractBaseUser, BaseModel): """Mailboxes for users from mail domain.""" first_name = models.CharField(max_length=200, blank=False) @@ -216,6 +225,13 @@ class Mailbox(BaseModel): default=MailboxStatusChoices.PENDING, ) + # Store the denormalized email address to allow Django admin to work (USERNAME_FIELD) + # This field *must* not be used for authentication (or anything sensitive), + # use the `local_part` and `domain__name` fields + dn_email = models.EmailField(_("email"), blank=True, unique=True, editable=False) + + USERNAME_FIELD = "dn_email" + class Meta: db_table = "people_mail_box" verbose_name = _("Mailbox") @@ -241,9 +257,19 @@ class Mailbox(BaseModel): Override save function to not allow to create or update mailbox of a disabled domain. """ self.full_clean() + self.dn_email = self.get_email() if self.domain.status == MailDomainStatusChoices.DISABLED: raise exceptions.ValidationError( _("You can't create or update a mailbox for a disabled domain.") ) return super().save(*args, **kwargs) + + @property + def is_active(self): + """Return True if the mailbox is enabled.""" + return self.status == MailboxStatusChoices.ENABLED + + def get_email(self): + """Return the email address of the mailbox.""" + return f"{self.local_part}@{self.domain.name}" diff --git a/src/backend/mailbox_manager/utils/dimail.py b/src/backend/mailbox_manager/utils/dimail.py index 1860b8b..f67d16f 100644 --- a/src/backend/mailbox_manager/utils/dimail.py +++ b/src/backend/mailbox_manager/utils/dimail.py @@ -8,6 +8,7 @@ from email.headerregistry import Address from logging import getLogger from django.conf import settings +from django.contrib.auth.hashers import make_password from django.contrib.sites.models import Site from django.core import exceptions, mail from django.template.loader import render_to_string @@ -341,6 +342,7 @@ class DimailAPIClient: # secondary email is mandatory. Unfortunately, dimail doesn't # store any. We temporarily give current email as secondary email. status=enums.MailboxStatusChoices.ENABLED, + password=make_password(None), # unusable password ) imported_mailboxes.append(str(mailbox)) else: diff --git a/src/backend/mailbox_oauth2/__init__.py b/src/backend/mailbox_oauth2/__init__.py new file mode 100644 index 0000000..0cee214 --- /dev/null +++ b/src/backend/mailbox_oauth2/__init__.py @@ -0,0 +1 @@ +"""People application to allow OAuth2 authentication with OIDC provider using mailbox.""" diff --git a/src/backend/mailbox_oauth2/apps.py b/src/backend/mailbox_oauth2/apps.py new file mode 100644 index 0000000..0cee214 --- /dev/null +++ b/src/backend/mailbox_oauth2/apps.py @@ -0,0 +1 @@ +"""People application to allow OAuth2 authentication with OIDC provider using mailbox.""" diff --git a/src/backend/mailbox_oauth2/backends.py b/src/backend/mailbox_oauth2/backends.py new file mode 100644 index 0000000..cfb05f4 --- /dev/null +++ b/src/backend/mailbox_oauth2/backends.py @@ -0,0 +1,129 @@ +"""Authentication backend for OIDC provider""" + +import logging +from email.errors import HeaderParseError +from email.headerregistry import Address + +from django.conf import settings +from django.contrib.auth.backends import ModelBackend +from django.core.cache import cache +from django.utils.text import slugify + +from mailbox_manager.models import Mailbox + +logger = logging.getLogger(__name__) + + +def get_username_domain_from_email(email: str): + """Extract local part and domain from email.""" + try: + address = Address(addr_spec=email) + if len(address.username) > 64 or len(address.domain) > 255: + # Simple length validation using the RFC 5321 limits + return None, None + return address.username, address.domain + except (TypeError, ValueError, AttributeError, IndexError, HeaderParseError) as exc: + logger.exception(exc) + return None, None + + +class MailboxModelBackend(ModelBackend): + """ + Custom authentication backend for OIDC provider, enforce the use of email as the username. + + Warning: This authentication backend is not suitable for general use, it is + tailored for the OIDC provider and will only authenticate user and allow + them to access the /o/authorize endpoint **only**. + """ + + def _get_cache_key(self, email): + """Generate a cache key for tracking login attempts.""" + stringified_email = email.replace("@", "_at_").replace(".", "_dot_") + return f"login_attempts_{slugify(stringified_email)}" + + def _increment_login_attempts(self, email): + """Increment the number of failed login attempts.""" + cache_key = self._get_cache_key(email) + attempts = cache.get(cache_key, 0) + 1 + cache.set(cache_key, attempts, settings.ACCOUNT_LOCKOUT_TIME) + + def _reset_login_attempts(self, email): + """Reset the number of failed login attempts.""" + cache_key = self._get_cache_key(email) + cache.delete(cache_key) + + def _is_login_attempts_exceeded(self, email) -> bool: + """Check if the account is locked due to too many failed attempts.""" + cache_key = self._get_cache_key(email) + attempts = cache.get(cache_key, 0) + return attempts >= settings.MAX_LOGIN_ATTEMPTS + + def get_user(self, user_id): + """Retrieve a user, here a mailbox, by its unique identifier.""" + try: + mailbox = Mailbox.objects.get(pk=user_id) + except Mailbox.DoesNotExist: + return None + + if self.user_can_authenticate(mailbox): + return mailbox + + return None + + def authenticate(self, request, username=None, password=None, email=None, **kwargs): + """Authenticate a user based on email and password""" + if username or email is None: # ignore if username is provided + return None + + # Disable this backend if the corresponding middleware is not defined. + if ( + "mailbox_oauth2.middleware.one_time_email_authenticated_session" + not in settings.MIDDLEWARE + ): + logger.error( + "EmailModelBackend was triggered but the `one_time_email_authenticated_session` " + "is not set: ignoring authentication." + ) + return None + + # Check if the account is locked + if self._is_login_attempts_exceeded(email): + logger.warning("Account locked due to too many failed attempts: %s", email) + # Run the default password hasher once to reduce the timing + # difference between a locked account and valid one (django issue #20760) + Mailbox().set_password(password) + return None + + local_part, domain = get_username_domain_from_email(email) + if local_part is None or domain is None: + return None + + try: + user = Mailbox.objects.select_related("domain").get( + local_part__iexact=local_part, domain__name__iexact=domain + ) + except Mailbox.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (django issue #20760). + Mailbox().set_password(password) + else: + if not self.user_can_authenticate(user): + # Run the default password hasher once to reduce the timing + # difference between a user who can authenticate and another one. + Mailbox().set_password(password) + + elif user.check_password(password): + # Reset attempts on successful login + self._reset_login_attempts(email) + return user + + else: + # Track failed attempt + self._increment_login_attempts(email) + + return None + + def user_can_authenticate(self, user): + """Verify the user can authenticate.""" + user_can_authenticate = super().user_can_authenticate(user) + return user_can_authenticate and user.domain.is_identity_provider_ready() diff --git a/src/backend/mailbox_oauth2/middleware.py b/src/backend/mailbox_oauth2/middleware.py new file mode 100644 index 0000000..4ab321f --- /dev/null +++ b/src/backend/mailbox_oauth2/middleware.py @@ -0,0 +1,50 @@ +"""Middleware to allow a user to authenticate against "/o/authorize" only once.""" + +from django.contrib.auth import BACKEND_SESSION_KEY +from django.contrib.sessions.exceptions import SuspiciousSession +from django.urls import reverse + + +def one_time_email_authenticated_session(get_response): + """Middleware to allow a user to authenticate against "/o/authorize" only once.""" + + def middleware(request): + # Code executed for each request before the view (and later middleware) are called. + if not request.user.is_authenticated: + # If user is not authenticated, proceed: + # this is not this middleware's concern + return get_response(request) + + # Check if the auth backend is stored in session + auth_backend = request.session.get(BACKEND_SESSION_KEY) + + if auth_backend != "mailbox_oauth2.backends.MailboxModelBackend": + # If the backend is not MailboxModelBackend, proceed: + # this is not this middleware's concern + return get_response(request) + + # Allow access only to /o/authorize path + if request.path != reverse("oauth2_provider:authorize"): + # Kill the session immediately + request.session.flush() + raise SuspiciousSession( + "Session was killed because user tried to access unauthorized path" + ) + + response = get_response(request) + + # Code executed for each request/response after the view is called. + # When the response is a 200, the user might still be in the authentication flow + # otherwise, we can kill the session as the current login process is done, + # the user will be authenticated again when coming back from the OIDC federation. + # We preserve the oidc_states for the case when the user wants to access people + # in the first place (people -> keycloak -> people login -> keycloak -> people) + if response.status_code != 200: + state = request.session.get("oidc_states") + request.session.flush() + if state: + request.session["oidc_states"] = state + + return response + + return middleware diff --git a/src/backend/mailbox_oauth2/migrations/0001_initial.py b/src/backend/mailbox_oauth2/migrations/0001_initial.py new file mode 100644 index 0000000..5cd79ae --- /dev/null +++ b/src/backend/mailbox_oauth2/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 5.1.5 on 2025-02-07 10:49 + +import django.db.models.deletion +import oauth2_provider.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('mailbox_manager', '0023_mailbox_email_mailbox_last_login_mailbox_password'), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Grant', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('code', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('redirect_uri', models.TextField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('code_challenge', models.CharField(blank=True, default='', max_length=128)), + ('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)), + ('nonce', models.CharField(blank=True, default='', max_length=255)), + ('claims', models.TextField(blank=True)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AccessToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.TextField()), + ('token_checksum', oauth2_provider.models.TokenChecksumField(db_index=True, max_length=64, unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RefreshToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255)), + ('token_family', models.UUIDField(blank=True, editable=False, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), + ('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to='mailbox_manager.mailbox')), + ], + options={ + 'abstract': False, + 'unique_together': {('token', 'revoked')}, + }, + ), + migrations.AddField( + model_name='accesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + ] diff --git a/src/backend/mailbox_oauth2/migrations/__init__.py b/src/backend/mailbox_oauth2/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/mailbox_oauth2/models.py b/src/backend/mailbox_oauth2/models.py new file mode 100644 index 0000000..7d455e3 --- /dev/null +++ b/src/backend/mailbox_oauth2/models.py @@ -0,0 +1,78 @@ +""" +Project custom models for the OAuth2 provider. + +We replace the former user model with a mailbox model. +""" + +from django.db import models + +from oauth2_provider.models import ( + AbstractAccessToken, + AbstractGrant, + AbstractIDToken, + AbstractRefreshToken, +) + + +class Grant(AbstractGrant): + """ + A Grant instance represents a token with a short lifetime that can + be swapped for an access token, as described in :rfc:`4.1.2` + + Replaces the user with a mailbox instance. + """ + + user = models.ForeignKey( + "mailbox_manager.Mailbox", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s", + ) + + +class IDToken(AbstractIDToken): + """ + An IDToken instance represents the actual token to + access user's resources, as in :openid:`2`. + + Replaces the user with a mailbox instance. + """ + + user = models.ForeignKey( + "mailbox_manager.Mailbox", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + ) + + +class AccessToken(AbstractAccessToken): + """ + An AccessToken instance represents the actual access token to + access user's resources, as in :rfc:`5`. + + Replaces the user with a mailbox instance. + """ + + user = models.ForeignKey( + "mailbox_manager.Mailbox", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + ) + + +class RefreshToken(AbstractRefreshToken): + """ + A RefreshToken instance represents a token that can be swapped for a new + access token when it expires. + + Replaces the user with a mailbox instance. + """ + + user = models.ForeignKey( + "mailbox_manager.Mailbox", + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s", + ) diff --git a/src/backend/mailbox_oauth2/serializers.py b/src/backend/mailbox_oauth2/serializers.py new file mode 100644 index 0000000..f1ed049 --- /dev/null +++ b/src/backend/mailbox_oauth2/serializers.py @@ -0,0 +1,48 @@ +""" +Serializers for the mailbox_oauth2 app. +""" + +from django.contrib.auth import authenticate + +from rest_framework import serializers + + +class LoginSerializer(serializers.Serializer): + """ + Serializer for user login authentication. + + Validates the email and password fields required for user authentication. + """ + + email = serializers.EmailField( + help_text="User's email address for authentication", + required=True, + ) + password = serializers.CharField( + write_only=True, + help_text="User's password for authentication", + required=True, + ) + + def create(self, validated_data): + """Do not allow creating instances from this serializer.""" + raise RuntimeError("LoginSerializer does not support create method") + + def update(self, instance, validated_data): + """Do not allow updating instances from this serializer.""" + raise RuntimeError("LoginSerializer does not support update method") + + def validate(self, attrs): + """ + Validate the email and password fields. + """ + email = attrs.get("email") + password = attrs.get("password") + + if not (email and password): + raise serializers.ValidationError('Must include "email" and "password"') + + attrs["user"] = authenticate( + self.context["request"], email=email, password=password + ) + return attrs diff --git a/src/backend/mailbox_oauth2/tests/__init__.py b/src/backend/mailbox_oauth2/tests/__init__.py new file mode 100644 index 0000000..b733625 --- /dev/null +++ b/src/backend/mailbox_oauth2/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for OAuth2 authentication with OIDC provider using mailbox.""" diff --git a/src/backend/mailbox_oauth2/tests/test_backends.py b/src/backend/mailbox_oauth2/tests/test_backends.py new file mode 100644 index 0000000..b11c863 --- /dev/null +++ b/src/backend/mailbox_oauth2/tests/test_backends.py @@ -0,0 +1,270 @@ +"""Test authentication backend for OIDC provider.""" + +from django.contrib.auth.hashers import make_password + +import pytest + +from core import factories as core_factories + +from mailbox_manager import factories +from mailbox_oauth2.backends import MailboxModelBackend, get_username_domain_from_email + +pytestmark = pytest.mark.django_db + + +def test_authenticate_valid_credentials(): + """Test authentication with valid credentials.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + + assert domain.is_identity_provider_ready() + + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password="password" + ) + + assert authenticated_user == mailbox + + # Is case insensitive + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part.upper()}@{domain.name}", password="password" + ) + + assert authenticated_user == mailbox + + +def test_authenticate_no_organization(): + """Test authentication when domain don't have organization.""" + domain = factories.MailDomainEnabledFactory() + mailbox = factories.MailboxEnabledFactory(domain=domain) + + assert not domain.is_identity_provider_ready() + + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password="password" + ) + + assert authenticated_user is None + + +def test_authenticate_invalid_email_format(): + """Test authentication with invalid email format.""" + + authenticated_user = MailboxModelBackend().authenticate( + None, email="invalid-email", password="any-password" + ) + + assert authenticated_user is None + + +def test_authenticate_nonexistent_user(): + """Test authentication with non-existent user.""" + authenticated_user = MailboxModelBackend().authenticate( + None, email="nonexistent@domain.com", password="any-password" + ) + + assert authenticated_user is None + + +def test_authenticate_wrong_password(): + """Test authentication with wrong password.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + + assert domain.is_identity_provider_ready() + + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password="wrong-password" + ) + + assert authenticated_user is None + + +def test_authenticate_without_middleware(): + """Test authentication without required middleware.""" + authenticated_user = MailboxModelBackend().authenticate( + None, email="any@domain.com", password="any-password" + ) + + assert authenticated_user is None + + +def test_authenticate_inactive_domain(): + """Test authentication with inactive identity provider domain.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + + assert not domain.is_identity_provider_ready() + + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password="password" + ) + + assert authenticated_user is None + + +def test_authenticate_unusable_password(): + """ + Test authentication with valid mailbox. but with unusable password. + This test is important because we use "make_password(None)" to generate + an unusable password for the mailbox (instead of set_unusable_password() + which would require an extra query). + """ + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + mailbox.password = make_password(None) + mailbox.save() + + assert domain.is_identity_provider_ready() + + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password=mailbox.password + ) + + assert authenticated_user is None + + +def test_get_user_exists(): + """Test get_user with existing user.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + + user = MailboxModelBackend().get_user(mailbox.pk) + + assert user == mailbox + + +def test_get_user_does_not_exist(): + """Test get_user with non-existent user.""" + user = MailboxModelBackend().get_user(999999) + + assert user is None + + +def test_authenticate_with_username_only(): + """Test authentication fails when only username is provided (no email).""" + authenticated_user = MailboxModelBackend().authenticate( + None, username="test", password="password" + ) + assert authenticated_user is None + + +def test_authenticate_sql_injection(): + """Test authentication is safe against SQL injection attempts.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + + # Test SQL injection in email field + authenticated_user = MailboxModelBackend().authenticate( + None, email="' OR '1'='1", password="password" + ) + assert authenticated_user is None + + # Test SQL injection in password field + authenticated_user = MailboxModelBackend().authenticate( + None, email=f"{mailbox.local_part}@{domain.name}", password="' OR '1'='1" + ) + assert authenticated_user is None + + +def test_get_inactive_mailbox(): + """Test get_user with inactive user.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxFactory(domain=domain) + + user = MailboxModelBackend().get_user(mailbox.pk) + assert user is None + + +@pytest.mark.parametrize( + "email, expected_username, expected_domain", + [ + ("test@example.com", "test", "example.com"), + ("test+label@example.com", "test+label", "example.com"), + ("invalid-email", None, None), + ("", None, None), + ("üser@exämple.com", None, None), + ("user@admin@example.com", None, None), + ("unicodeonly@üser.com", "unicodeonly", "üser.com"), + ("spaces in@domain.com", None, None), + ("verylong" + "a" * 1000 + "@example.com", None, None), + ("verylong@ex" + "a" * 1000 + "mple.com", None, None), + ("weird\0char@domain.com", None, None), + ("@domain.com", None, None), + ("username@", None, None), + ("quotes'and,commas@example.com", None, None), + ], +) +def test_get_username_domain_from_email(email, expected_username, expected_domain): + """Test extracting username and domain from email.""" + username, domain = get_username_domain_from_email(email) + assert username == expected_username + assert domain == expected_domain + + +def test_login_attempts_tracking(settings): + """Test tracking of failed login attempts.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + email = f"{mailbox.local_part}@{domain.name}" + backend = MailboxModelBackend() + + # First attempts should allow authentication (but fail due to wrong password) + for _ in range(settings.MAX_LOGIN_ATTEMPTS - 1): + authenticated_user = backend.authenticate( + None, email=email, password="wrong-password" + ) + assert authenticated_user is None + + # Last attempt before lockout + authenticated_user = backend.authenticate( + None, email=email, password="wrong-password" + ) + assert authenticated_user is None + + # Account should now be locked + authenticated_user = backend.authenticate(None, email=email, password="password") + assert authenticated_user is None + + +def test_login_attempts_reset_on_success(settings): + """Test that successful login resets the failed attempts counter.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory(organization=organization) + mailbox = factories.MailboxEnabledFactory(domain=domain) + email = f"{mailbox.local_part}@{domain.name}" + backend = MailboxModelBackend() + + # Make some failed attempts + for _ in range(settings.MAX_LOGIN_ATTEMPTS - 1): + authenticated_user = backend.authenticate( + None, email=email, password="wrong-password" + ) + assert authenticated_user is None + + # Successful login should reset counter + authenticated_user = backend.authenticate(None, email=email, password="password") + assert authenticated_user == mailbox + + # Should be able to attempt again after reset + authenticated_user = backend.authenticate( + None, email=email, password="wrong-password" + ) + assert authenticated_user is None + + +def test_login_attempts_cache_key(): + """Test the cache key generation for login attempts.""" + backend = MailboxModelBackend() + email = "test@example.com" + + cache_key = backend._get_cache_key(email) # pylint: disable=protected-access + assert cache_key == "login_attempts_test_at_example_dot_com" diff --git a/src/backend/mailbox_oauth2/tests/test_middleware.py b/src/backend/mailbox_oauth2/tests/test_middleware.py new file mode 100644 index 0000000..e7407f6 --- /dev/null +++ b/src/backend/mailbox_oauth2/tests/test_middleware.py @@ -0,0 +1,150 @@ +""" +Tests for the mailbox_oauth2.middleware.one_time_email_authenticated_session +middleware. +""" + +import pytest +from oauth2_provider.models import Application + +from core import factories as core_factories + +from mailbox_manager import factories + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="authorize_data") +def authorize_data_fixture(): + """Return the authorize data for the OIDC IdP process.""" + yield { + "scope": "openid", + "state": ( + "3C0yk2i25wrx6fNf9zn9287idFqFHGsGIu7UhuJaP0I" + ".xYBWCFWCFmQ.hpSB0Fd0TmS8MP7cfFiVjw" + ".eyJydSI6Imh0dHBzOi8vZGVzay4xMjcuMC4wLjEubml" + "wLmlvL2FwaS92MS4wL2NhbGxiYWNrLyIsInJ0IjoiY29" + "kZSIsInN0IjoiZ2MzazVSdzREZ0tySERBbHlYaW9vaXg" + "wa2IzVkMyMTMifQ" + ), + "response_type": "code", + "client_id": "people-idp", + "redirect_uri": "https://test", + "acr_values": "eidas1", + "code_challenge": "36Tcgz62tUu7XvNj_g_jYu6IBi-j7BL-5ZwkW-rI9qc", + "code_challenge_method": "S256", + "nonce": "9CTyx0RNzP6kkywLyK6pwQ", + } + + +def test_one_time_email_authenticated_session_flow_unallowed_url(client): + """ + Test the middleware with a user that is authenticated during the + OIDC IdP process cannot access random pages. + """ + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory( + organization=organization, name="example.com" + ) + factories.MailboxEnabledFactory(domain=domain, local_part="user") + + response = client.post( + "/api/v1.0/login/", {"email": "user@example.com", "password": "password"} + ) + assert response.status_code == 200 + + # assert the user has a session + assert not client.session.is_empty() + + response = client.get("/api/v1.0/users/me/") + assert response.status_code == 400 + + # assert the user has no more session + assert client.session.is_empty() + + +def test_one_time_email_authenticated_session_flow_allowed_url(client, authorize_data): + """ + Test the middleware with a user that is authenticated during the + OIDC IdP process can access the OIDC authorize page. + """ + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory( + organization=organization, name="example.com" + ) + mailbox = factories.MailboxEnabledFactory(domain=domain, local_part="user") + + response = client.post( + "/api/v1.0/login/", {"email": "user@example.com", "password": "password"} + ) + assert response.status_code == 200 + + # assert the user has a session + assert not client.session.is_empty() + + # to properly test the /o/authorize/ we need to setup OIDC identity provider + Application.objects.create( + name="people-idp", + client_id="people-idp", + redirect_uris="https://test", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + response = client.get( + f"/o/authorize/?{'&'.join(f'{k}={v}' for k, v in authorize_data.items())}" + ) + assert response.status_code == 200 + assert response.context["user"] == mailbox + + # assert the user has a session + assert not client.session.is_empty() + + response = client.post( + "/o/authorize/", + authorize_data, + ) + assert response.status_code == 302 + + # assert the user has no more session + assert client.session.is_empty() + + +def test_one_time_email_authenticated_session_flow_allowed_url_skip_authorization( + client, + authorize_data, +): + """ + Test the middleware with a user that is authenticated during the + OIDC IdP process can access the OIDC authorize page. + """ + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory( + organization=organization, name="example.com" + ) + factories.MailboxEnabledFactory(domain=domain, local_part="user") + + response = client.post( + "/api/v1.0/login/", {"email": "user@example.com", "password": "password"} + ) + assert response.status_code == 200 + + # assert the user has a session + assert not client.session.is_empty() + + # to properly test the /o/authorize/ we need to setup OIDC identity provider + Application.objects.create( + name="people-idp", + client_id="people-idp", + redirect_uris="https://test", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + skip_authorization=True, + ) + + response = client.get( + f"/o/authorize/?{'&'.join(f'{k}={v}' for k, v in authorize_data.items())}" + ) + assert response.status_code == 302 + + # assert the user has no more session + assert client.session.is_empty() diff --git a/src/backend/mailbox_oauth2/tests/test_validators.py b/src/backend/mailbox_oauth2/tests/test_validators.py new file mode 100644 index 0000000..1050533 --- /dev/null +++ b/src/backend/mailbox_oauth2/tests/test_validators.py @@ -0,0 +1,194 @@ +"""Tests for OAuth2 validators.""" + +from django.contrib.auth.models import AnonymousUser + +import pytest +from oauth2_provider.models import Application + +from core import factories as core_factories + +from mailbox_manager import factories +from mailbox_oauth2.validators import BaseValidator, ProConnectValidator + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="mailbox") +def mailbox_fixture(): + """Create a mailbox for testing.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory( + organization=organization, name="example.com" + ) + return factories.MailboxEnabledFactory( + domain=domain, local_part="user", first_name="John", last_name="Doe" + ) + + +@pytest.fixture(name="oauth_request_authenticated") +def oauth_request_authenticated_fixture(mailbox): + """Create a mock OAuth request object with authenticated user.""" + + class MockRequest: # pylint: disable=missing-class-docstring + def __init__(self): + self.user = mailbox + self.scopes = set() + self.claims = None + self.acr_values = None + + return MockRequest() + + +@pytest.fixture(name="oauth_request_anonymous") +def oauth_request_anonymous_fixture(): + """Create a mock OAuth request object with anonymous user.""" + + class MockRequest: # pylint: disable=missing-class-docstring + def __init__(self): + self.user = AnonymousUser() + self.scopes = set() + self.claims = None + self.acr_values = None + + return MockRequest() + + +@pytest.fixture(name="oauth_request_for_auth_code") +def oauth_request_for_auth_code_fixture(oauth_request_authenticated): + """Create a mock OAuth request object with full authorization code attributes.""" + + class MockRequestWithClient: # pylint: disable=missing-class-docstring,too-many-instance-attributes + def __init__(self, base_request): + self.user = base_request.user + self.scopes = {"openid"} + self.claims = None + self.acr_values = None + # Required OAuth2 attributes for authorization code + self.client = Application.objects.create( + name="test_app", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + skip_authorization=True, + ) + self.redirect_uri = "https://example.com/callback" + self.code_challenge = "test_challenge" + self.code_challenge_method = "S256" + self.nonce = "test_nonce" + + return MockRequestWithClient(oauth_request_authenticated) + + +# Base Validator Tests +def test_get_additional_claims_basic(oauth_request_authenticated): + """Test basic additional claims without scopes.""" + validator = BaseValidator() + claims = validator.get_additional_claims(oauth_request_authenticated) + + assert claims["sub"] == str(oauth_request_authenticated.user.pk) + assert claims["amr"] == "pwd" + assert "email" not in claims + + +def test_get_additional_claims_with_email_scope(oauth_request_authenticated): + """Test additional claims with email scope.""" + validator = BaseValidator() + oauth_request_authenticated.scopes = {"email"} + claims = validator.get_additional_claims(oauth_request_authenticated) + + assert claims["email"] == oauth_request_authenticated.user.get_email() + + +def test_validate_silent_authorization_authenticated(oauth_request_authenticated): + """Test silent authorization with authenticated user.""" + validator = BaseValidator() + assert validator.validate_silent_authorization(oauth_request_authenticated) is True + + +def test_validate_silent_authorization_unauthenticated(oauth_request_anonymous): + """Test silent authorization with unauthenticated user.""" + validator = BaseValidator() + assert validator.validate_silent_authorization(oauth_request_anonymous) is False + + +def test_validate_silent_login_authenticated(oauth_request_authenticated): + """Test silent login with authenticated user.""" + validator = BaseValidator() + assert validator.validate_silent_login(oauth_request_authenticated) is True + + +def test_validate_silent_login_unauthenticated(oauth_request_anonymous): + """Test silent login with unauthenticated user.""" + validator = BaseValidator() + assert validator.validate_silent_login(oauth_request_anonymous) is False + + +def test_introspect_token_not_implemented(): + """Test that introspect_token raises RuntimeError.""" + validator = BaseValidator() + with pytest.raises(RuntimeError, match="Introspection not implemented"): + validator.introspect_token(None, None, None) + + +# ProConnect Validator Tests +def test_proconnect_get_additional_claims_given_name(oauth_request_authenticated): + """Test getting given_name claim.""" + validator = ProConnectValidator() + oauth_request_authenticated.scopes = {"given_name"} + claims = validator.get_additional_claims(oauth_request_authenticated) + assert claims["given_name"] == "John" + + +def test_proconnect_get_additional_claims_usual_name(oauth_request_authenticated): + """Test getting usual_name claim.""" + validator = ProConnectValidator() + oauth_request_authenticated.scopes = {"usual_name"} + claims = validator.get_additional_claims(oauth_request_authenticated) + assert claims["usual_name"] == "Doe" + + +def test_proconnect_get_additional_claims_siret(oauth_request_authenticated): + """Test getting siret claim.""" + validator = ProConnectValidator() + oauth_request_authenticated.scopes = {"siret"} + claims = validator.get_additional_claims(oauth_request_authenticated) + assert ( + claims["siret"] + == oauth_request_authenticated.user.domain.organization.registration_id_list[0] + ) + + +def test_proconnect_get_additional_claims_with_acr_claim(oauth_request_authenticated): + """Test getting acr claim when eidas1 is requested.""" + validator = ProConnectValidator() + oauth_request_authenticated.claims = {"acr": "eidas1"} + claims = validator.get_additional_claims(oauth_request_authenticated) + assert claims["acr"] == "eidas1" + + +def test_proconnect_get_additional_claims_without_acr_claim( + oauth_request_authenticated, +): + """Test no acr claim when not requested.""" + validator = ProConnectValidator() + claims = validator.get_additional_claims(oauth_request_authenticated) + assert "acr" not in claims + + +def test_proconnect_create_authorization_code_with_eidas1(oauth_request_for_auth_code): + """Test creating authorization code with eidas1 acr value.""" + validator = ProConnectValidator() + oauth_request_for_auth_code.acr_values = "eidas1" + code = {"code": "test_code"} # OAuth2 provider expects a dict with 'code' key + validator._create_authorization_code(oauth_request_for_auth_code, code) # pylint: disable=protected-access + assert oauth_request_for_auth_code.claims == {"acr": "eidas1"} + + +def test_proconnect_create_authorization_code_without_eidas1( + oauth_request_for_auth_code, +): + """Test creating authorization code without eidas1 acr value.""" + validator = ProConnectValidator() + oauth_request_for_auth_code.acr_values = "other_value" + code = {"code": "test_code"} # OAuth2 provider expects a dict with 'code' key + validator._create_authorization_code(oauth_request_for_auth_code, code) # pylint: disable=protected-access + assert not oauth_request_for_auth_code.claims diff --git a/src/backend/mailbox_oauth2/tests/test_views.py b/src/backend/mailbox_oauth2/tests/test_views.py new file mode 100644 index 0000000..9000ddd --- /dev/null +++ b/src/backend/mailbox_oauth2/tests/test_views.py @@ -0,0 +1,83 @@ +"""Tests for the mailbox_oauth2.views module.""" + +import datetime + +from django.utils import timezone + +import pytest + +from core import factories as core_factories + +from mailbox_manager import factories + +pytestmark = pytest.mark.django_db + + +def test_login_view_options(client): + """Test the OPTIONS method on the login view.""" + response = client.options("/api/v1.0/login/") + + assert response.status_code == 200 + assert response.headers == { + "Content-Type": "application/json", + "Vary": "Accept, Authorization, origin, Accept-Language, Cookie", + "Allow": "POST, OPTIONS", + "Content-Length": "209", + "X-Frame-Options": "DENY", + "Content-Language": "en-us", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + "Cross-Origin-Opener-Policy": "same-origin", + } + + +def test_login_view_authorize(client): + """Test the login view with valid data.""" + organization = core_factories.OrganizationFactory(with_registration_id=True) + domain = factories.MailDomainEnabledFactory( + organization=organization, name="example.com" + ) + factories.MailboxEnabledFactory(domain=domain, local_part="user") + + response = client.post( + "/api/v1.0/login/", {"email": "user@example.com", "password": "password"} + ) + assert response.status_code == 200 + + # assert the user has a session + assert not client.session.is_empty() + assert client.session.get_expiry_date() < timezone.now() + datetime.timedelta( + minutes=1 + ) + + assert response.headers == { + "Content-Type": "application/json", + "Vary": "Accept, Authorization, Cookie, origin, Accept-Language", + "Allow": "POST, OPTIONS", + "Content-Length": "36", + "X-Frame-Options": "DENY", + "Content-Language": "en-us", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + "Cross-Origin-Opener-Policy": "same-origin", + } + + +@pytest.mark.parametrize( + "email, password, status_code, response_json", + [ + ("", "password", 400, {"email": ["This field may not be blank."]}), + ( + "email@test.com", + "", + 400, + {"password": ["This field may not be blank."]}, + ), + ], +) +def test_login_view_invalid_data(client, email, password, status_code, response_json): + """Test the login view with invalid data.""" + response = client.post("/api/v1.0/login/", {"email": email, "password": password}) + + assert response.status_code == status_code + assert response.json() == response_json diff --git a/src/backend/mailbox_oauth2/urls.py b/src/backend/mailbox_oauth2/urls.py new file mode 100644 index 0000000..1fc92c5 --- /dev/null +++ b/src/backend/mailbox_oauth2/urls.py @@ -0,0 +1,9 @@ +"""URLs for the mailbox_oauth2 app.""" + +from django.urls import path + +from .views import LoginView + +urlpatterns = [ + path("login/", LoginView.as_view(), name="api_login"), +] diff --git a/src/backend/mailbox_oauth2/validators.py b/src/backend/mailbox_oauth2/validators.py new file mode 100644 index 0000000..5ec84d0 --- /dev/null +++ b/src/backend/mailbox_oauth2/validators.py @@ -0,0 +1,180 @@ +""" +Module for OIDC authentication. + +Contains all related code for OIDC authentication using +people as an Identity Provider. +""" + +from oauth2_provider.oauth2_validators import OAuth2Validator + + +class BaseValidator(OAuth2Validator): + """This validator adds additional claims to the token based on the requested scopes.""" + + def get_additional_claims(self, request): + """ + Generate additional claims to be included in the token. + Warning, here the request.user is a Mailbox object. + + Args: + request: The OAuth2 request object containing user and scope information. + + Returns: + dict: A dictionary of additional claims to be included in the token. + """ + additional_claims = super().get_additional_claims(request) + + # Enforce the use of the sub instead of the user pk as sub + additional_claims["sub"] = str(request.user.pk) + + # Authentication method reference + additional_claims["amr"] = "pwd" + + # Include the user's email if 'email' scope is requested + if "email" in request.scopes: + additional_claims["email"] = request.user.get_email() + + return additional_claims + + def introspect_token(self, token, token_type_hint, request, *args, **kwargs): + """Introspect an access or refresh token. + + Called once the introspect request is validated. This method should + verify the *token* and either return a dictionary with the list of + claims associated, or `None` in case the token is unknown. + + Below the list of registered claims you should be interested in: + + - scope : space-separated list of scopes + - client_id : client identifier + - username : human-readable identifier for the resource owner + - token_type : type of the token + - exp : integer timestamp indicating when this token will expire + - iat : integer timestamp indicating when this token was issued + - nbf : integer timestamp indicating when it can be "not-before" used + - sub : subject of the token - identifier of the resource owner + - aud : list of string identifiers representing the intended audience + - iss : string representing issuer of this token + - jti : string identifier for the token + + Note that most of them are coming directly from JWT RFC. More details + can be found in `Introspect Claims`_ or `JWT Claims`_. + + The implementation can use *token_type_hint* to improve lookup + efficiency, but must fallback to other types to be compliant with RFC. + + The dict of claims is added to request.token after this method. + """ + raise RuntimeError("Introspection not implemented") + + def validate_silent_authorization(self, request): + """Ensure the logged in user has authorized silent OpenID authorization. + + Silent OpenID authorization allows access tokens and id tokens to be + granted to clients without any user prompt or interaction. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid + """ + return request.user.is_authenticated + + def validate_silent_login(self, request): + """Ensure session user has authorized silent OpenID login. + + If no user is logged in or has not authorized silent login, this + method should return False. + + If the user is logged in but associated with multiple accounts and + not selected which one to link to the token then this method should + raise an oauthlib.oauth2.AccountSelectionRequired error. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - OpenIDConnectAuthCode + - OpenIDConnectImplicit + - OpenIDConnectHybrid + """ + return request.user.is_authenticated + + +class ProConnectValidator(BaseValidator): + """ + This validator adds additional claims to be compatible with + the french ProConnect API, but not only. + """ + + oidc_claim_scope = OAuth2Validator.oidc_claim_scope | { + "given_name": "given_name", + "usual_name": "usual_name", + "siret": "profile", + } + + def get_additional_claims(self, request): + """ + Generate additional claims to be included in the token. + + Args: + request: The OAuth2 request object containing user and scope information. + + Returns: + dict: A dictionary of additional claims to be included in the token. + """ + additional_claims = super().get_additional_claims(request) + + # Include the user's name if 'profile' scope is requested + if "given_name" in request.scopes: + additional_claims["given_name"] = request.user.first_name + + if "usual_name" in request.scopes: + additional_claims["usual_name"] = request.user.last_name + + if "siret" in request.scopes: + # The following line will fail on purpose if we don't have the proper information + additional_claims["siret"] = ( + request.user.domain.organization.registration_id_list[0] + ) + + # Include 'acr' claim if it is present in the request claims and equals 'eidas1' + # see _create_authorization_code method for more details + if request.claims and request.claims.get("acr") == "eidas1": + additional_claims["acr"] = "eidas1" + + return additional_claims + + def _create_authorization_code(self, request, code, expires=None): + """ + Create an authorization code and handle 'acr_values' in the request. + + Args: + request: The OAuth2 request object containing user and scope information. + code: The authorization code to be created. + expires: The expiration time of the authorization code. + + Returns: + The created authorization code. + """ + # Split and strip 'acr_values' from the request, if present + acr_values = ( + [value.strip() for value in request.acr_values.split(",")] + if request.acr_values + else [] + ) + + # If 'eidas1' is in 'acr_values', add 'acr' claim to the request claims + # This allows the token to have this information and pass it to the /token + # endpoint and return it in the token response + if "eidas1" in acr_values: + request.claims = request.claims or {} + request.claims["acr"] = "eidas1" + + # Call the superclass method to create the authorization code + return super()._create_authorization_code(request, code, expires) diff --git a/src/backend/mailbox_oauth2/views.py b/src/backend/mailbox_oauth2/views.py new file mode 100644 index 0000000..e9a6cfd --- /dev/null +++ b/src/backend/mailbox_oauth2/views.py @@ -0,0 +1,43 @@ +"""Views for handling OAuth2 authentication via API.""" + +import datetime + +from django.contrib.auth import login + +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.status import HTTP_401_UNAUTHORIZED +from rest_framework.views import APIView + +from .serializers import LoginSerializer + + +class LoginView(APIView): + """Login view to allow users to authenticate and create a session from the frontend.""" + + permission_classes = [AllowAny] + + def post(self, request): + """ + Authenticate user and create session. + """ + serializer = LoginSerializer(data=request.data, context={"request": request}) + serializer.is_valid(raise_exception=True) + + # User is None if the credentials are invalid + user = serializer.validated_data["user"] + + if user is not None: + login(request, user) + # In this context we need a session long enough to allow the user to + # authenticate to make the OIDC loop. A minute should be enough. + # Even if the session is longer, the one_time_email_authenticated_session + # middleware will kill the session as soon as the user tries to access + # paths outside the OIDC process or when the OIDC process is done here. + request.session.set_expiry(datetime.timedelta(minutes=1)) + return Response({"message": "Successfully logged in"}) + + return Response( + {"error": "Invalid credentials"}, + status=HTTP_401_UNAUTHORIZED, + ) diff --git a/src/backend/people/api_urls.py b/src/backend/people/api_urls.py index 9e05750..ee8927a 100644 --- a/src/backend/people/api_urls.py +++ b/src/backend/people/api_urls.py @@ -9,6 +9,8 @@ from core.api.client import viewsets from core.authentication.urls import urlpatterns as oidc_urls from core.resource_server.urls import urlpatterns as resource_server_urls +from mailbox_oauth2.urls import urlpatterns as mailbox_oauth2_urls + # - Main endpoints router = DefaultRouter() router.register("contacts", viewsets.ContactViewSet, basename="contacts") @@ -40,6 +42,7 @@ urlpatterns = [ include( [ *router.urls, + *mailbox_oauth2_urls, *oidc_urls, *resource_server_urls, re_path( diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 8caf92a..634cb10 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -17,6 +17,8 @@ from django.utils.translation import gettext_lazy as _ import sentry_sdk from configurations import Configuration, values +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger @@ -196,12 +198,15 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "mailbox_oauth2.middleware.one_time_email_authenticated_session", + "oauth2_provider.middleware.OAuth2TokenMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", ] AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", + "mailbox_oauth2.backends.MailboxModelBackend", "core.authentication.backends.OIDCAuthenticationBackend", ] @@ -212,15 +217,17 @@ class Base(Configuration): "core", "demo", "mailbox_manager", + "mailbox_oauth2", "drf_spectacular", "drf_spectacular_sidecar", # required for Django collectstatic discovery # Third party apps "corsheaders", "dockerflow.django", - "rest_framework", - "parler", - "treebeard", "easy_thumbnails", + "oauth2_provider", + "parler", + "rest_framework", + "treebeard", # Django "django.contrib.auth", "django.contrib.contenttypes", @@ -535,6 +542,26 @@ class Base(Configuration): environ_prefix=None, ) + OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" + OAUTH2_PROVIDER_GRANT_MODEL = "mailbox_oauth2.Grant" + OAUTH2_PROVIDER_ID_TOKEN_MODEL = "mailbox_oauth2.IDToken" # noqa: S105 + OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "mailbox_oauth2.AccessToken" # noqa: S105 + OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "mailbox_oauth2.RefreshToken" # noqa: S105 + + # Security settings for login attempts + # - Maximum number of failed login attempts before lockout + MAX_LOGIN_ATTEMPTS = values.IntegerValue( + default=5, + environ_name="MAX_LOGIN_ATTEMPTS", + environ_prefix=None, + ) + # - Lockout time in seconds (default to 5 minutes) + ACCOUNT_LOCKOUT_TIME = values.IntegerValue( + default=5 * 60, + environ_name="ACCOUNT_LOCKOUT_TIME", + environ_prefix=None, + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): @@ -594,6 +621,46 @@ class Base(Configuration): }, } + @property + def OAUTH2_PROVIDER(self) -> dict: + """OAuth2 Provider settings.""" + OIDC_ENABLED = values.BooleanValue( + default=False, + environ_name="OAUTH2_PROVIDER_OIDC_ENABLED", + environ_prefix=None, + ) + OIDC_RSA_PRIVATE_KEY = values.Value( + environ_name="OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY", + environ_prefix=None, + ) + OAUTH2_VALIDATOR_CLASS = values.Value( + default="mailbox_oauth2.validators.BaseValidator", + environ_name="OAUTH2_PROVIDER_VALIDATOR_CLASS", + environ_prefix=None, + ) + SCOPES = { + "openid": "OpenID Connect scope", + "email": "Email address", + } + if OAUTH2_VALIDATOR_CLASS == "mailbox_oauth2.validators.ProConnectValidator": + SCOPES["given_name"] = "First name" + SCOPES["usual_name"] = "Last name" + SCOPES["siret"] = "SIRET number" + + return { + "OIDC_ENABLED": OIDC_ENABLED, + "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, + "SCOPES": SCOPES, + "OAUTH2_VALIDATOR_CLASS": OAUTH2_VALIDATOR_CLASS, + } + + @property + def LOGIN_URL(self): + """ + Define the LOGIN_URL (Django) for the OIDC provider (reuse LOGIN_REDIRECT_URL) + """ + return f"{self.LOGIN_REDIRECT_URL}/login/" + @classmethod def post_setup(cls): """Post setup configuration. diff --git a/src/backend/people/urls.py b/src/backend/people/urls.py index 8978885..7993364 100644 --- a/src/backend/people/urls.py +++ b/src/backend/people/urls.py @@ -4,13 +4,14 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import path, re_path +from django.urls import include, path, re_path from drf_spectacular.views import ( SpectacularJSONAPIView, SpectacularRedocView, SpectacularSwaggerView, ) +from oauth2_provider import urls as oauth2_urls from debug import urls as debug_urls @@ -21,6 +22,7 @@ API_VERSION = settings.API_VERSION urlpatterns = ( [ path("admin/", admin.site.urls), + path("o/", include(oauth2_urls)), ] + api_urls.urlpatterns + resource_server_urls.urlpatterns diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index e418bd3..3d2f1ca 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -25,34 +25,36 @@ license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.10" dependencies = [ - "boto3==1.36.26", "Brotli==1.1.0", + "PyJWT==2.10.1", + "boto3==1.36.26", "celery[redis]==5.4.0", "django-configurations==2.5.1", "django-cors-headers==4.7.0", "django-countries==7.6.1", + "django-oauth-toolkit==3.0.1", "django-parler==2.3", - "django-treebeard==4.7.1", - "redis==5.2.1", "django-redis==5.4.0", "django-storages==1.14.5", "django-timezone-field>=5.1", + "django-treebeard==4.7.1", "django==5.1.6", "djangorestframework==3.15.2", - "drf_spectacular[sidecar]==0.28.0", "dockerflow==2024.4.2", + "drf_spectacular==0.28.0", + "drf_spectacular[sidecar]==0.28.0", "easy_thumbnails==2.10", "factory_boy==3.3.3", "gunicorn==23.0.0", + "joserfc==1.0.3", "jsonschema==4.23.0", + "mozilla-django-oidc==4.0.1", "nested-multipart-parser==1.5.0", "psycopg[binary]==3.2.5", - "PyJWT==2.10.1", - "joserfc==1.0.3", + "redis==5.2.1", "requests==2.32.3", "sentry-sdk[django]==2.22.0", "whitenoise==6.9.0", - "mozilla-django-oidc==4.0.1", ] [project.urls]