mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-11 10:37:12 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bc34c99f |
No files matched your search
@@ -794,6 +794,17 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to None
|
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}
|
#### [`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.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Workflows
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from urllib.parse import quote
|
|||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.core import context
|
from allauth.core import context
|
||||||
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
||||||
|
from allauth.mfa.stages import AuthenticateStage
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
@@ -19,7 +20,34 @@ from paperless.signals import handle_social_account_updated
|
|||||||
logger = logging.getLogger("paperless.auth")
|
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):
|
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):
|
def is_open_for_signup(self, request):
|
||||||
"""
|
"""
|
||||||
Check whether the site is open for signups, which can be
|
Check whether the site is open for signups, which can be
|
||||||
|
|||||||
@@ -348,6 +348,11 @@ SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP: Final[str | None] = os.getenv(
|
|||||||
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv(
|
SOCIAL_ACCOUNT_SYNC_STAFF_GROUP: Final[str | None] = os.getenv(
|
||||||
"PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP",
|
"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"
|
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ import logging
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from allauth.account.adapter import get_adapter
|
from allauth.account.adapter import get_adapter
|
||||||
|
from allauth.account.models import Login
|
||||||
from allauth.core import context
|
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.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 AnonymousUser
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ValidationError
|
from django.forms import ValidationError
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.test import RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from pytest_django.fixtures import Settings
|
from pytest_django.fixtures import Settings
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from paperless.adapter import DrfTokenStrategy
|
from paperless.adapter import DrfTokenStrategy
|
||||||
|
from paperless.adapter import SocialAccountAuthenticateStage
|
||||||
from paperless_testing.factories import UserFactory
|
from paperless_testing.factories import UserFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -129,6 +136,89 @@ class TestCustomAccountAdapter:
|
|||||||
assert not user2.is_superuser
|
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:
|
class TestCustomSocialAccountAdapter:
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user