diff --git a/docs/configuration.md b/docs/configuration.md index 3257a815c..93a926075 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -794,6 +794,17 @@ system. See the corresponding Defaults to None +#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=`](#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) {#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR} + +: A list of authentication method values which, if present in the `amr` claim of the ID token sent by an OpenID Connect provider, cause Paperless-ngx to skip its own two-factor authentication prompt for that login. This avoids users having to enter a second factor twice when the identity provider already performed multi-factor authentication. Logins with a username and password, or via a provider which does not send a matching `amr` value, still require the Paperless-ngx code. + + Which values to trust depends entirely on your identity provider, e.g. Authelia sends `mfa` and Pocket ID sends `phr` for passkey logins (but `otp` for single-factor email codes, which should _not_ be trusted). Some providers, such as Keycloak, do not send an `amr` claim by default and need to be configured to do so. You can see what your provider sends from the Django admin as a superuser by checking `id_token` → `amr` in the "Extra data" of the social account. + + !!! warning + Only list values that mean a second factor was actually used for the login. + + Defaults to `mfa`, the standard value for multi-factor authentication ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html)). + #### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS} : A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist. diff --git a/docs/usage.md b/docs/usage.md index a5bd40140..9bc7d935d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -473,6 +473,8 @@ Users can enable two-factor authentication (2FA) for their accounts from the 'My Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen. +If users log in via an OpenID Connect provider which already enforces multi-factor authentication, see [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR`](configuration.md#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) to skip the Paperless-ngx prompt for those logins. + ## Workflows !!! note diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py index bd6a86f77..51e751043 100644 --- a/src/paperless/adapter.py +++ b/src/paperless/adapter.py @@ -4,6 +4,7 @@ from urllib.parse import quote from allauth.account.adapter import DefaultAccountAdapter from allauth.core import context from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy +from allauth.mfa.stages import AuthenticateStage from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings from django.contrib.auth.models import Group @@ -19,7 +20,34 @@ from paperless.signals import handle_social_account_updated logger = logging.getLogger("paperless.auth") +class SocialAccountAuthenticateStage(AuthenticateStage): + """ + Skips the 2FA prompt for a social login if the ID token's `amr` claim shows the + identity provider already used one of the SOCIAL_ACCOUNT_MFA_TRUSTED_AMR methods. + """ + + def _should_handle(self, request: HttpRequest) -> bool: + sociallogin = (self.login.signal_kwargs or {}).get("sociallogin") + if sociallogin is not None and settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR: + id_token = (sociallogin.account.extra_data or {}).get("id_token") or {} + amr = id_token.get("amr") or [] + if set(amr) & set(settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR): + logger.debug( + f"Skipping 2FA for `{self.login.user}`, provider `{sociallogin.account.provider}` reported amr {amr}", + ) + return False + return super()._should_handle(request) + + class CustomAccountAdapter(DefaultAccountAdapter): + def get_login_stages(self) -> list[str]: + return [ + "paperless.adapter.SocialAccountAuthenticateStage" + if stage == "allauth.mfa.stages.AuthenticateStage" + else stage + for stage in super().get_login_stages() + ] + def is_open_for_signup(self, request): """ Check whether the site is open for signups, which can be diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 7ca3bb66e..bf8e7cab2 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -348,6 +348,11 @@ SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: Final[str | None] = os.getenv( SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv( "PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP", ) +SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = [ + value.strip() + for value in os.getenv("PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR", "mfa").split(",") + if value.strip() +] HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py index b7c330332..2e8be0b20 100644 --- a/src/paperless/tests/test_adapter.py +++ b/src/paperless/tests/test_adapter.py @@ -2,19 +2,26 @@ import logging import pytest from allauth.account.adapter import get_adapter +from allauth.account.models import Login from allauth.core import context +from allauth.mfa.totp.internal.auth import TOTP +from allauth.mfa.totp.internal.auth import generate_totp_secret from allauth.socialaccount.adapter import get_adapter as get_social_adapter +from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.models import SocialLogin from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError from django.http import HttpRequest +from django.test import RequestFactory from django.urls import reverse from pytest_django.fixtures import Settings from pytest_mock import MockerFixture from rest_framework.authtoken.models import Token from paperless.adapter import DrfTokenStrategy +from paperless.adapter import SocialAccountAuthenticateStage from paperless_testing.factories import UserFactory @@ -129,6 +136,89 @@ class TestCustomAccountAdapter: assert not user2.is_superuser +@pytest.mark.django_db +class TestSocialAccountAuthenticateStage: + @staticmethod + def _should_handle(user: User, extra_data: dict | None) -> bool: + signal_kwargs = None + if extra_data is not None: + account = SocialAccount( + provider="openid_connect", + uid="uid", + extra_data=extra_data, + ) + signal_kwargs = {"sociallogin": SocialLogin(user=user, account=account)} + request = RequestFactory().get("/") + request.session = {} + stage = SocialAccountAuthenticateStage( + None, + request, + Login(user=user, signal_kwargs=signal_kwargs), + ) + return stage._should_handle(request) + + def test_stage_replaces_allauth_stage(self) -> None: + stages = get_adapter().get_login_stages() + + assert "paperless.adapter.SocialAccountAuthenticateStage" in stages + assert "allauth.mfa.stages.AuthenticateStage" not in stages + + @pytest.mark.parametrize( + ("trusted_amr", "extra_data", "expected"), + [ + pytest.param( + ["mfa"], + {"id_token": {"amr": ["pwd", "mfa"]}}, + False, + id="trusted-amr", + ), + pytest.param( + ["phr"], + {"id_token": {"amr": ["otp"]}}, + True, + id="untrusted-amr", + ), + pytest.param([], {"id_token": {"amr": ["mfa"]}}, True, id="setting-unset"), + pytest.param(["mfa"], {"id_token": {}}, True, id="no-amr-claim"), + pytest.param( + ["mfa"], + {"userinfo": {"amr": ["mfa"]}}, + True, + id="userinfo-only", + ), + pytest.param(["mfa"], None, True, id="regular-login"), + ], + ) + def test_mfa_skipped_only_for_trusted_amr( + self, + settings: Settings, + trusted_amr: list[str], + extra_data: dict | None, + *, + expected: bool, + ) -> None: + """ + GIVEN: + - A user with TOTP enabled + WHEN: + - The user logs in, via a social account or not + THEN: + - The 2FA prompt is only skipped if the ID token's amr claim contains + one of SOCIAL_ACCOUNT_MFA_TRUSTED_AMR + """ + settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = trusted_amr + user = UserFactory(username="testuser") + TOTP.activate(user, generate_totp_secret()) + + assert self._should_handle(user, extra_data) is expected + + def test_no_mfa_for_user_without_authenticator(self, settings: Settings) -> None: + settings.SOCIAL_ACCOUNT_MFA_TRUSTED_AMR = ["mfa"] + user = UserFactory(username="testuser") + + assert not self._should_handle(user, {"id_token": {"amr": ["pwd"]}}) + + class TestCustomSocialAccountAdapter: @pytest.mark.django_db def test_is_open_for_signup(self, settings: Settings) -> None: