mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-22 23:58:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b68ce4b977 |
No files matched your search
@@ -1,53 +1,62 @@
|
||||
name: Install apt packages
|
||||
description: >
|
||||
Installs apt packages with a per-attempt timeout and retries. The hosted runner's Azure apt mirror intermittently serves archives at a crawl, which apt's own timeout does not catch. A stuck attempt is killed and retried, which usually lands on a healthy mirror node.
|
||||
Caches apt packages and their dependencies for an exact hosted runner image. Cache hits install offline; cache misses download from Ubuntu mirrors with bounded retries.
|
||||
|
||||
inputs:
|
||||
packages:
|
||||
description: Space separated list of packages to install
|
||||
description: Space separated list of package names to install
|
||||
required: true
|
||||
attempts:
|
||||
description: Maximum number of attempts
|
||||
description: Maximum number of download attempts
|
||||
default: "3"
|
||||
attempt-timeout:
|
||||
description: Seconds before a single attempt is killed
|
||||
description: Seconds before a single download attempt is killed
|
||||
default: "180"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Identify apt cache
|
||||
id: cache-key
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGES: ${{ inputs.packages }}
|
||||
run: |
|
||||
# The bundle contains only dependencies missing from this runner. Never
|
||||
# restore it onto a different image or initial package installation.
|
||||
# Without an image version (e.g. self-hosted), download normally.
|
||||
if [ -n "${ImageVersion:-}" ]; then
|
||||
fingerprint=$({
|
||||
printf '%s\n' "$PACKAGES"
|
||||
dpkg --print-architecture
|
||||
dpkg-query -W -f='${binary:Package}=${Version} ${db:Status-Abbrev}\n'
|
||||
sha256sum "$GITHUB_ACTION_PATH/action.yml" "$GITHUB_ACTION_PATH/install.sh" | cut -d ' ' -f 1
|
||||
} | sha256sum | cut -d ' ' -f 1)
|
||||
echo "key=apt-v1-${ImageOS}-${ImageVersion}-${fingerprint}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Restore apt packages
|
||||
id: cache
|
||||
if: steps.cache-key.outputs.key != ''
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/apt-packages/*.deb
|
||||
${{ runner.temp }}/apt-packages/complete
|
||||
key: ${{ steps.cache-key.outputs.key }}
|
||||
- name: Install ${{ inputs.packages }}
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGES: ${{ inputs.packages }}
|
||||
ATTEMPTS: ${{ inputs.attempts }}
|
||||
ATTEMPT_TIMEOUT: ${{ inputs.attempt-timeout }}
|
||||
run: |
|
||||
SUDO=""
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
attempt_install() {
|
||||
$SUDO apt-get update -q
|
||||
# Intentionally unquoted, this is a list of package names
|
||||
$SUDO apt-get install -q --yes --no-install-recommends $PACKAGES
|
||||
}
|
||||
export -f attempt_install
|
||||
export SUDO PACKAGES
|
||||
|
||||
for attempt in $(seq 1 "${ATTEMPTS}"); do
|
||||
echo "::group::apt install attempt ${attempt}/${ATTEMPTS}"
|
||||
start=$(date +%s)
|
||||
if timeout --kill-after=10 "${ATTEMPT_TIMEOUT}" bash -c attempt_install; then
|
||||
echo "::endgroup::"
|
||||
echo "Installed in $(( $(date +%s) - start ))s"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::warning::apt install attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||
# A killed attempt can leave dpkg half configured
|
||||
$SUDO dpkg --configure -a || true
|
||||
done
|
||||
|
||||
echo "::error::apt install failed after ${ATTEMPTS} attempts"
|
||||
exit 1
|
||||
CACHE_DIR: ${{ runner.temp }}/apt-packages
|
||||
CACHE_HIT: ${{ steps.cache.outputs.cache-hit }}
|
||||
run: bash "$GITHUB_ACTION_PATH/install.sh"
|
||||
# Save immediately: a later test failure must not discard a good apt bundle.
|
||||
- name: Save apt packages
|
||||
if: steps.cache-key.outputs.key != '' && steps.cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/apt-packages/*.deb
|
||||
${{ runner.temp }}/apt-packages/complete
|
||||
key: ${{ steps.cache-key.outputs.key }}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SUDO=()
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
SUDO=(sudo)
|
||||
fi
|
||||
|
||||
# Inputs are package names, separated by whitespace (not shell expressions).
|
||||
read -r -a requested_packages <<< "$PACKAGES"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
|
||||
install_local() {
|
||||
local archives=("$CACHE_DIR"/*.deb)
|
||||
if [ -e "${archives[0]}" ]; then
|
||||
# Local .deb arguments pin the downloaded versions. No package lists or
|
||||
# network access are needed, and apt still runs all package maintainer scripts.
|
||||
"${SUDO[@]}" chmod a+r "${archives[@]}"
|
||||
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
|
||||
--no-download -o "Dir::Cache::archives=$CACHE_DIR" "${archives[@]}"
|
||||
fi
|
||||
# An empty bundle is valid when the runner already includes every package.
|
||||
local package
|
||||
for package in "${requested_packages[@]}"; do
|
||||
[ "$(dpkg-query -W -f='${Status}' "$package")" = "install ok installed" ]
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$CACHE_HIT" = true ]; then
|
||||
echo "Installing cached apt packages without contacting package repositories"
|
||||
install_local
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The hosted runner gives Azure priority 1. A slow transfer never triggers its
|
||||
# fallback mirrors, so retrying alone keeps selecting the same unhealthy mirror.
|
||||
if [ -f /etc/apt/apt-mirrors.txt ]; then
|
||||
"${SUDO[@]}" sed -i '\|azure\.archive\.ubuntu\.com|d' /etc/apt/apt-mirrors.txt
|
||||
fi
|
||||
|
||||
# Download first so the bundle contains every missing transitive dependency.
|
||||
# Keep downloads outside apt's default cache, which Docker cleanup hooks may wipe.
|
||||
download_packages() {
|
||||
"${SUDO[@]}" apt-get update -q --error-on=any
|
||||
"${SUDO[@]}" apt-get install -q --yes --no-install-recommends \
|
||||
--download-only -o "Dir::Cache::archives=$CACHE_DIR" "${requested_packages[@]}"
|
||||
}
|
||||
# Recreate the sudo and package arrays in the timed child process.
|
||||
if [ "${1:-}" = download ]; then
|
||||
download_packages
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||
echo "::group::apt download attempt ${attempt}/${ATTEMPTS}"
|
||||
if timeout --kill-after=10 "$ATTEMPT_TIMEOUT" bash "$0" download; then
|
||||
echo "::endgroup::"
|
||||
install_local
|
||||
touch "$CACHE_DIR/complete"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
echo "::warning::apt download attempt ${attempt}/${ATTEMPTS} failed or exceeded ${ATTEMPT_TIMEOUT}s"
|
||||
done
|
||||
|
||||
echo "::error::apt download failed after ${ATTEMPTS} attempts"
|
||||
exit 1
|
||||
@@ -76,6 +76,7 @@ jobs:
|
||||
- 'uv.lock'
|
||||
- 'docker/compose/docker-compose.ci-test.yml'
|
||||
- '.github/workflows/ci-backend.yml'
|
||||
- '.github/actions/apt-install/**'
|
||||
test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.backend_changed == 'true'
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
frontend:
|
||||
- 'src-ui/**'
|
||||
- '.github/workflows/ci-frontend.yml'
|
||||
- '.github/actions/apt-install/**'
|
||||
install-dependencies:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
|
||||
@@ -803,17 +803,6 @@ 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.
|
||||
|
||||
@@ -472,8 +472,6 @@ 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
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -20,38 +19,7 @@ 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 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):
|
||||
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
|
||||
|
||||
@@ -349,10 +349,6 @@ 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 = get_list_from_env(
|
||||
"PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR",
|
||||
"mfa",
|
||||
)
|
||||
|
||||
HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy"
|
||||
|
||||
|
||||
@@ -2,26 +2,19 @@ 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
|
||||
|
||||
|
||||
@@ -136,120 +129,6 @@ class TestCustomAccountAdapter:
|
||||
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:
|
||||
@pytest.mark.django_db
|
||||
def test_is_open_for_signup(self, settings: Settings) -> None:
|
||||
|
||||
Reference in new issue
Block a user