This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
people/src/backend/mailbox_oauth2/backends.py
Quentin BEY db6cdadd72 (oidc) add django-oauth-toolkit w/ configuration
This allows to use `people` as an identity provider using
OIDC and local users.
This commit is partial, because it does not manage a way to
create "local" users and the login page is the admin one, which
can't be used for non staff users or login with email.
2025-03-03 12:24:43 +01:00

130 lines
4.9 KiB
Python

"""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()