Enhancement: skip local 2FA for trusted OIDC AMR

This commit is contained in:
shamoon committed 2026-10-10 12:27:39 -07:00
1 parent 138160cbda
commit f3bc34c99f
5 files changed
+136

No files matched your search

+11
View File
@@ -794,6 +794,17 @@ system. See the corresponding
Defaults to None
#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=<comma-separated-list>`](#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=<comma-separated-list>`](#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.
+2
View File
@@ -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
+28
View File
@@ -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
+5
View File
@@ -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"
+90
View File
@@ -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: