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.
130 lines
4.9 KiB
Python
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()
|