mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-21 07:18:13 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
493a41645a | ||
|
|
19043de89e | ||
|
|
adb8d5fa30 | ||
|
|
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,38 @@ 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 handle(self) -> tuple:
|
||||||
|
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")
|
||||||
|
amr = id_token.get("amr") if isinstance(id_token, dict) else None
|
||||||
|
if (
|
||||||
|
isinstance(amr, list)
|
||||||
|
and all(isinstance(value, str) for value in amr)
|
||||||
|
and 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 None, True
|
||||||
|
return super().handle()
|
||||||
|
|
||||||
|
|
||||||
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,10 @@ 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 = get_list_from_env(
|
||||||
|
"PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR",
|
||||||
|
"mfa",
|
||||||
|
)
|
||||||
|
|
||||||
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,120 @@ class TestCustomAccountAdapter:
|
|||||||
assert not user2.is_superuser
|
assert not user2.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestSocialAccountAuthenticateStage:
|
||||||
|
@staticmethod
|
||||||
|
def _requires_mfa(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),
|
||||||
|
)
|
||||||
|
response, cont = stage.handle()
|
||||||
|
assert cont is True
|
||||||
|
requires_mfa = response is not None
|
||||||
|
assert stage.state.get("authentication_required", False) is requires_mfa
|
||||||
|
if requires_mfa:
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response.url == reverse("mfa_authenticate")
|
||||||
|
return requires_mfa
|
||||||
|
|
||||||
|
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(["m"], {"id_token": {"amr": "mfa"}}, True, id="string-amr"),
|
||||||
|
pytest.param(
|
||||||
|
["mfa"],
|
||||||
|
{"id_token": {"amr": {"mfa": 1}}},
|
||||||
|
True,
|
||||||
|
id="dict-amr",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
["mfa"],
|
||||||
|
{"id_token": {"amr": [["mfa"]]}},
|
||||||
|
True,
|
||||||
|
id="nested-amr",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
["mfa"],
|
||||||
|
{"id_token": {"amr": ["mfa", 1]}},
|
||||||
|
True,
|
||||||
|
id="mixed-amr",
|
||||||
|
),
|
||||||
|
pytest.param(["mfa"], {"id_token": {"amr": None}}, True, id="null-amr"),
|
||||||
|
pytest.param(["mfa"], {"id_token": {"amr": []}}, True, id="empty-amr"),
|
||||||
|
pytest.param(["mfa"], {"id_token": "mfa"}, True, id="string-id-token"),
|
||||||
|
pytest.param(["mfa"], {"id_token": ["mfa"]}, True, id="list-id-token"),
|
||||||
|
pytest.param(["mfa"], {"id_token": None}, True, id="null-id-token"),
|
||||||
|
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._requires_mfa(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._requires_mfa(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