Compare commits

...
24 changed files with 1897 additions and 984 deletions

No files matched your search

+7 -1
View File
@@ -20,7 +20,13 @@ def main() -> None:
"name": f"main/{path.stem}",
"paths": [test_path],
"paths_arg": test_path,
"extra": "ldap" if test_path.endswith("/test_auth_ldap.py") else "",
"extra": (
"ldap"
if test_path.endswith("/test_auth_ldap.py")
else "allauth"
if test_path.endswith("/test_allauth_integration.py")
else ""
),
"count": 1,
},
)
+24
View File
@@ -0,0 +1,24 @@
"""
django-allauth integration for ArchiveBox.
Provides social login, email/password auth, and configurable user registration
as a replacement for the default admin-only login flow.
Only activates if ALLAUTH_ENABLED=True in config and django-allauth is installed.
To install allauth support:
pip install archivebox[allauth]
# or
uv add "django-allauth[socialaccount]>=65.0"
To configure social providers, set SOCIALACCOUNT_PROVIDERS in ArchiveBox.conf:
[ALLAUTH_CONFIG]
ALLAUTH_ENABLED = true
SOCIALACCOUNT_ENABLED = true
REGISTRATION_MODE = open # open | invite | approval
EMAIL_VERIFICATION = none # none | optional | mandatory
SOCIALACCOUNT_PROVIDERS = {"github": {"APP": {"client_id": "...", "secret": "..."}}}
"""
__package__ = "archivebox.auth"
+73
View File
@@ -0,0 +1,73 @@
__package__ = "archivebox.auth"
import importlib
try:
DefaultAccountAdapter = importlib.import_module("allauth.account.adapter").DefaultAccountAdapter
DefaultSocialAccountAdapter = importlib.import_module("allauth.socialaccount.adapter").DefaultSocialAccountAdapter
except (ImportError, RuntimeError):
class DefaultAccountAdapter: # type: ignore[no-redef]
"""Stub when django-allauth is not installed."""
pass
class DefaultSocialAccountAdapter: # type: ignore[no-redef]
"""Stub when django-allauth is not installed."""
pass
def _get_registration_enabled() -> bool:
from archivebox.config.common import get_config
return get_config().REGISTRATION_ENABLED
def _get_registration_mode() -> str:
from archivebox.config.common import get_config
return get_config().REGISTRATION_MODE
class ArchiveBoxAccountAdapter(DefaultAccountAdapter):
"""
Custom account adapter that enforces ArchiveBox registration policy:
- REGISTRATION_ENABLED=False → signup form is closed entirely
- REGISTRATION_MODE="invite" → signup form is closed (invite link required)
- REGISTRATION_MODE="approval" → user is saved inactive, pending admin activation
"""
def is_open_for_signup(self, request):
if not _get_registration_enabled():
return False
if _get_registration_mode() == "invite":
return False
return True
def save_user(self, request, user, form, commit=True):
user = super().save_user(request, user, form, commit=False)
if _get_registration_mode() == "approval":
user.is_active = False
if commit:
user.save()
return user
class ArchiveBoxSocialAccountAdapter(DefaultSocialAccountAdapter):
"""
Custom social account adapter that enforces the same registration policy
as ArchiveBoxAccountAdapter for social logins.
Auto-connecting social accounts by verified email is controlled by the
django-allauth setting SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT,
which ArchiveBox exposes as SOCIALACCOUNT_EMAIL_AUTO_CONNECT (default: False).
See archivebox/config/allauth.py for the security rationale.
"""
def is_open_for_signup(self, request, sociallogin):
if not _get_registration_enabled():
return False
if _get_registration_mode() == "invite":
return False
return True
+13
View File
@@ -0,0 +1,13 @@
__package__ = "archivebox.auth"
from django.apps import AppConfig
class AuthConfig(AppConfig):
name = "archivebox.auth"
label = "archivebox_auth"
verbose_name = "ArchiveBox Auth"
def ready(self):
# Connect signal handlers
from archivebox.auth import signals # noqa: F401
+34
View File
@@ -0,0 +1,34 @@
__package__ = "archivebox.auth"
from django.dispatch import receiver
def _get_default_permissions() -> str:
from archivebox.config.common import get_config
return get_config().DEFAULT_USER_PERMISSIONS
def _apply_default_permissions(user) -> None:
"""Apply DEFAULT_USER_PERMISSIONS to a newly signed-up user."""
level = _get_default_permissions()
if level == "none":
return
if level == "admin":
user.is_staff = True
user.is_superuser = True
user.save(update_fields=["is_staff", "is_superuser"])
return
try:
from allauth.account.signals import user_signed_up
@receiver(user_signed_up)
def on_user_signed_up(sender, request, user, **kwargs):
_apply_default_permissions(user)
except ImportError:
pass
@@ -0,0 +1,19 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content_title %}<center>{% trans "Confirm your email address." %}</center><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
{% if confirmation %}
<p>{% blocktrans with email=confirmation.email_address.email %}Please confirm that <strong>{{ email }}</strong> is your email address by clicking below.{% endblocktrans %}</p>
<form method="POST" action="{% url 'account_confirm_email' confirmation.key %}">
{% csrf_token %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Confirm email' %}">
</div>
</form>
{% else %}
<p class="errornote">{% trans "This email confirmation link is invalid or has expired." %}</p>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "admin/base_site.html" %}
{% load i18n static allauth account socialaccount %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
<a href="{% url 'Home' %}">
{% block branding %}<h1>ArchiveBox</h1>{% endblock %}
</a>
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}
<center>Log in to your archive.</center><br/><br/>
<img src="{% static 'archive.png' %}" style="width: 80px; display: block; margin: auto"/><br/>
{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
{% if form.errors %}
<p class="errornote">{% trans "Please correct the error below." %}</p>
{% endif %}
{{ form.non_field_errors }}
<form method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }}{{ field }}
</div>
{% endfor %}
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}">
{% endif %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Log in' %}">
</div>
</form>
{% get_providers as socialaccount_providers %}
{% if socialaccount_providers %}
<hr/>
<center><strong>{% trans "Or sign in with:" %}</strong></center><br/>
{% include "socialaccount/snippets/provider_list.html" with process="login" %}
{% endif %}
{% if ACCOUNT_ALLOW_REGISTRATION %}
<hr/>
<center>
<a href="{% url 'account_signup' %}">{% trans "Create an account" %}</a>
&nbsp;|&nbsp;
<a href="{% url 'account_reset_password' %}">{% trans "Forgot your password?" %}</a>
</center>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
<a href="{% url 'Home' %}">{% block branding %}<h1>ArchiveBox</h1>{% endblock %}</a>
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}<center>Reset your password.</center><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
<form method="POST" action="{% url 'account_reset_password' %}">
{% csrf_token %}
{% for field in form %}
<div class="form-row">{{ field.errors }}{{ field.label_tag }}{{ field }}</div>
{% endfor %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Send reset link' %}">
</div>
</form>
<hr/>
<center><a href="{% url 'account_login' %}">{% trans "Back to login" %}</a></center>
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
<a href="{% url 'Home' %}">{% block branding %}<h1>ArchiveBox</h1>{% endblock %}</a>
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}<center>Set a new password.</center><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
{% if token_fail %}
<p class="errornote">{% trans "This password reset link is invalid or has expired. Please request a new one." %}</p>
<center><a href="{% url 'account_reset_password' %}">{% trans "Request new link" %}</a></center>
{% else %}
<form method="POST">
{% csrf_token %}
{% for field in form %}
<div class="form-row">{{ field.errors }}{{ field.label_tag }}{{ field }}</div>
{% endfor %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Set new password' %}">
</div>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
<a href="{% url 'Home' %}">{% block branding %}<h1>ArchiveBox</h1>{% endblock %}</a>
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}<center>Create an account.</center><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
{% if form.errors %}
<p class="errornote">{% trans "Please correct the error below." %}</p>
{% endif %}
{{ form.non_field_errors }}
<form method="POST" action="{% url 'account_signup' %}">
{% csrf_token %}
{% for field in form %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }}{{ field }}
</div>
{% endfor %}
{% if redirect_field_value %}
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}">
{% endif %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Sign up' %}">
</div>
</form>
<hr/>
<center><a href="{% url 'account_login' %}">{% trans "Already have an account? Log in" %}</a></center>
</div>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content_title %}<div style="text-align: center;">{% trans "Connected accounts." %}</div><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
{% if socialaccounts %}
<p>{% trans "The following social accounts are connected to your account:" %}</p>
<ul>
{% for account in socialaccounts %}
<li>{{ account.get_provider_display }}: {{ account.uid }}
<form method="POST" action="{% url 'socialaccount_connections' %}">
{% csrf_token %}
<input type="hidden" name="account" value="{{ account.id }}">
<button type="submit">{% trans "Disconnect" %}</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>{% trans "No social accounts connected." %}</p>
{% endif %}
<hr/>
<a href="{% url 'account_login' %}">{% trans "Back" %}</a>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "admin/base_site.html" %}
{% load i18n static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/login.css" %}">
{% endblock %}
{% block bodyclass %}{{ block.super }} login{% endblock %}
<a href="{% url 'Home' %}">{% block branding %}<h1>ArchiveBox</h1>{% endblock %}</a>
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}<center>Complete your sign-up.</center><br/>{% endblock %}
{% block breadcrumbs %}{% endblock %}
{% block content %}
<div id="content-main">
<p>{% blocktrans with provider=sociallogin.account.get_provider.name %}You are signing in with <strong>{{ provider }}</strong>. Please confirm your details below.{% endblocktrans %}</p>
<form method="POST" action="{% url 'socialaccount_signup' %}">
{% csrf_token %}
{{ redirect_field }}
{% for field in form %}
<div class="form-row">{{ field.errors }}{{ field.label_tag }}{{ field }}</div>
{% endfor %}
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Continue' %}">
</div>
</form>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
__package__ = "archivebox.config"
from pydantic import Field, PrivateAttr, field_validator
from archivebox.config.configset import BaseConfigSet
class AllauthConfig(BaseConfigSet):
"""
django-allauth authentication configuration.
Only activates if ALLAUTH_ENABLED=True and django-allauth is installed.
"""
toml_section_header: str = "ALLAUTH_CONFIG"
_scope: str = PrivateAttr(default="server")
ALLAUTH_ENABLED: bool = Field(default=False)
SOCIALACCOUNT_ENABLED: bool = Field(default=False)
REGISTRATION_ENABLED: bool = Field(default=True)
REGISTRATION_MODE: str = Field(default="open")
EMAIL_VERIFICATION: str = Field(default="none")
DEFAULT_USER_PERMISSIONS: str = Field(default="none")
SOCIALACCOUNT_PROVIDERS: dict = Field(default_factory=dict)
# SECURITY: Auto-connecting social accounts by verified email is disabled by default.
# When True, a social login whose provider-reported email matches an existing local
# account will be silently linked to that account without user confirmation.
# This creates an account-takeover risk if the OIDC/OAuth provider's email
# verification is weak or if the provider itself is compromised.
# Only enable this if you fully trust all configured social providers AND require
# mandatory email verification (EMAIL_VERIFICATION=mandatory) in your setup.
# See: https://docs.allauth.org/en/latest/socialaccount/configuration.html
SOCIALACCOUNT_EMAIL_AUTO_CONNECT: bool = Field(default=False)
@field_validator("REGISTRATION_MODE", mode="after")
@classmethod
def validate_registration_mode(cls, v: str) -> str:
allowed = {"open", "invite", "approval"}
if v not in allowed:
raise ValueError(f"REGISTRATION_MODE must be one of: {', '.join(sorted(allowed))}")
return v
@field_validator("EMAIL_VERIFICATION", mode="after")
@classmethod
def validate_email_verification(cls, v: str) -> str:
allowed = {"mandatory", "optional", "none"}
if v not in allowed:
raise ValueError(f"EMAIL_VERIFICATION must be one of: {', '.join(sorted(allowed))}")
return v
@field_validator("DEFAULT_USER_PERMISSIONS", mode="after")
@classmethod
def validate_default_user_permissions(cls, v: str) -> str:
allowed = {"none", "admin"}
if v not in allowed:
raise ValueError(f"DEFAULT_USER_PERMISSIONS must be one of: {', '.join(sorted(allowed))}")
return v
+10 -1
View File
@@ -24,6 +24,7 @@ from rich.console import Console
from archivebox.config.configset import COMPUTED_CONFIG_KEYS, BaseConfigSet, decode_config_inputs
from .allauth import AllauthConfig
from .constants import CONSTANTS
from .ldap import LDAPConfig
from .permissions import IN_DOCKER
@@ -112,6 +113,7 @@ def resolve_delete_after_config_value(*configs: Mapping[str, Any] | None) -> str
_SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD")
_SENSITIVE_CONFIG_KEY_EXACT = frozenset({"SOCIALACCOUNT_PROVIDERS"})
SENSITIVE_CONFIG_VALUE_REDACTED = "********"
_SCOPE_CRAWL_FROZEN = "crawl_frozen"
_SCOPE_CRAWL_EXECUTION = "crawl_execution"
@@ -140,7 +142,11 @@ def is_sensitive_config_key(key: str) -> bool:
"""
key = str(key or "")
upper = key.upper()
return key in _plugin_sensitive_config_keys() or any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
return (
key in _plugin_sensitive_config_keys()
or key in _SENSITIVE_CONFIG_KEY_EXACT
or any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
)
def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -641,6 +647,7 @@ class ArchiveBoxBaseConfig(
ArchivingConfig,
SearchBackendConfig,
LDAPConfig,
AllauthConfig,
):
"""Merged, typed ArchiveBox config.
@@ -671,6 +678,7 @@ class ArchiveBoxBaseConfig(
ArchivingConfig,
SearchBackendConfig,
LDAPConfig,
AllauthConfig,
)
@classmethod
@@ -1281,4 +1289,5 @@ def get_all_configs() -> dict[str, BaseConfigSet]:
"ARCHIVING_CONFIG": ArchivingConfig.model_validate(resolved),
"SEARCH_BACKEND_CONFIG": SearchBackendConfig.model_validate(resolved),
"LDAP_CONFIG": LDAPConfig.model_validate(resolved),
"ALLAUTH_CONFIG": AllauthConfig.model_validate(resolved),
}
+4 -2
View File
@@ -11,8 +11,10 @@ from admin_data_views.admin import (
from admin_data_views.admin import (
get_app_list as adv_get_app_list,
)
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model, login as auth_login
from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model
from django.contrib.auth import login as auth_login
from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.views import LoginView
@@ -31,7 +33,6 @@ from archivebox.core.routes_util import is_allowed_archivebox_redirect_url
if TYPE_CHECKING:
from admin_data_views.typing import AppDict
from django.http import HttpRequest
from django.template.response import TemplateResponse
from django.urls import URLPattern, URLResolver
@@ -114,6 +115,7 @@ class ArchiveBoxAdmin(admin.AdminSite):
"app_path": request.get_full_path(),
"username": request.user.get_username(),
"first_admin_setup": first_admin_setup,
"allauth_enabled": settings.ALLAUTH_ENABLED,
}
if REDIRECT_FIELD_NAME not in request.GET and REDIRECT_FIELD_NAME not in request.POST:
context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
+70 -3
View File
@@ -11,8 +11,8 @@ from pathlib import Path
from django.conf.locale.en import formats as en_formats # type: ignore
import archivebox
from archivebox.config.django import CONFIG
from archivebox.config.constants import CONSTANTS
from archivebox.config.django import CONFIG
from archivebox.core.routes_util import get_admin_base_url, get_api_base_url, get_base_url, normalize_base_url
# DATABASE_ENGINE config selects the backend (sqlite by default); the
@@ -111,7 +111,7 @@ MIDDLEWARE = [
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.RemoteUserBackend",
"django.contrib.auth.backends.ModelBackend",
# Additional auth backends (e.g., LDAP) configured via settings
# Additional auth backends (e.g., LDAP, allauth) configured via settings below
]
@@ -152,7 +152,8 @@ try:
"email": CONFIG.LDAP_EMAIL_ATTR,
}
# Use custom LDAP backend that supports LDAP_CREATE_SUPERUSER
# Use custom LDAP backend that supports LDAP_CREATE_SUPERUSER.
# The allauth block below prepends its backend when explicitly enabled.
AUTHENTICATION_BACKENDS = [
"archivebox.ldap.auth.ArchiveBoxLDAPBackend",
"django.contrib.auth.backends.RemoteUserBackend",
@@ -175,6 +176,72 @@ except ImportError:
# archivebox.config.ldap not available (shouldn't happen but handle gracefully)
pass
################################################################################
### django-allauth Configuration
# Installing an optional dependency must not silently change authentication.
# ALLAUTH_ENABLED is the single switch for apps, middleware, backend, and routes.
################################################################################
ALLAUTH_ENABLED = CONFIG.ALLAUTH_ENABLED
if ALLAUTH_ENABLED:
try:
import allauth # noqa: F401
except ImportError as err:
raise ImportError("ALLAUTH_ENABLED=True requires the archivebox[allauth] optional dependency") from err
INSTALLED_APPS += [
"archivebox.auth",
"allauth",
"allauth.account",
"allauth.socialaccount",
# All known providers installed; only activatable via SOCIALACCOUNT_PROVIDERS credentials
"allauth.socialaccount.providers.amazon",
"allauth.socialaccount.providers.apple",
"allauth.socialaccount.providers.discord",
"allauth.socialaccount.providers.facebook",
"allauth.socialaccount.providers.github",
"allauth.socialaccount.providers.gitlab",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.instagram",
"allauth.socialaccount.providers.linkedin_oauth2",
"allauth.socialaccount.providers.microsoft",
"allauth.socialaccount.providers.twitter_oauth2",
"allauth.socialaccount.providers.openid_connect",
]
MIDDLEWARE += [
"allauth.account.middleware.AccountMiddleware",
]
# Prepend allauth so its email authentication runs before Django's username backend.
_allauth_auth_backend = "allauth.account.auth_backends.AuthenticationBackend"
if _allauth_auth_backend not in AUTHENTICATION_BACKENDS:
AUTHENTICATION_BACKENDS = [_allauth_auth_backend] + AUTHENTICATION_BACKENDS
ACCOUNT_ADAPTER = "archivebox.auth.adapters.ArchiveBoxAccountAdapter"
SOCIALACCOUNT_ADAPTER = "archivebox.auth.adapters.ArchiveBoxSocialAccountAdapter"
ACCOUNT_LOGIN_METHODS = {"email"} # replaces deprecated ACCOUNT_AUTHENTICATION_METHOD
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_UNIQUE_EMAIL = True
EMAIL_VERIFICATION = CONFIG.EMAIL_VERIFICATION
ACCOUNT_EMAIL_VERIFICATION = CONFIG.EMAIL_VERIFICATION # "none" | "optional" | "mandatory"
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
# SECURITY: Auto-connecting social accounts by verified email is a known account-takeover
# vector when provider email verification is weak or the provider itself is compromised.
# django-allauth defaults this to False. ArchiveBox exposes it as an explicit opt-in via
# SOCIALACCOUNT_EMAIL_AUTO_CONNECT (default: False). Only enable in trusted, controlled
# deployments where all social providers enforce mandatory email verification.
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = CONFIG.SOCIALACCOUNT_EMAIL_AUTO_CONNECT
# Provider-specific config — instances set this via SOCIALACCOUNT_PROVIDERS env var (JSON dict)
# e.g. SOCIALACCOUNT_PROVIDERS='{"google": {"APP": {"client_id": "...", "secret": "..."}}}'
SOCIALACCOUNT_PROVIDERS = CONFIG.SOCIALACCOUNT_PROVIDERS
LOGIN_REDIRECT_URL = "/admin/"
ACCOUNT_LOGOUT_REDIRECT_URL = LOGOUT_REDIRECT_URL or "/"
################################################################################
### Staticfile and Template Settings
################################################################################
+23 -19
View File
@@ -3,30 +3,40 @@ __package__ = "archivebox.core"
from importlib.util import find_spec
from django.conf import settings
from django.urls import path, re_path, include
from django.http import HttpRequest
from django.urls import include, path, re_path
from django.views import static
from django.views.generic.base import RedirectView
from django.http import HttpRequest
from archivebox.config.constants import CONSTANTS
from archivebox.misc.serve_static import serve_static
from archivebox.core.admin_site import archivebox_admin
from archivebox.core.views import (
AddView,
HealthCheckView,
HomepageView,
SnapshotView,
SnapshotPathView,
SnapshotReplayView,
SnapshotReplayAuthView,
OriginalDomainReplayView,
PublicIndexView,
AddView,
SnapshotPathView,
SnapshotReplayAuthView,
SnapshotReplayView,
SnapshotView,
WebAddView,
HealthCheckView,
)
from archivebox.misc.serve_static import serve_static
from archivebox.opencode.views import opencode_proxy_view
from archivebox.progressmonitor.views import live_progress_view
from archivebox.search.views import public_snapshot_search_stream_view
from archivebox.opencode.views import opencode_proxy_view
if settings.ALLAUTH_ENABLED:
account_urlpatterns = [path("accounts/", include("allauth.urls"))]
else:
# Preserve ArchiveBox's built-in auth surface unless allauth was explicitly
# enabled. Optional package installation alone must not change login behavior.
account_urlpatterns = [
path("accounts/login/", RedirectView.as_view(url="/admin/login/", query_string=True)),
path("accounts/logout/", RedirectView.as_view(url="/admin/logout/", query_string=True)),
path("accounts/", include("django.contrib.auth.urls")),
]
urlpatterns = [
re_path(r"^static/(?P<path>.*)$", serve_static),
@@ -62,14 +72,8 @@ urlpatterns = [
path("admin/core/snapshot/add/", RedirectView.as_view(url="/add/")),
path("admin/core/snapshot/replay-auth/", SnapshotReplayAuthView.as_view(), name="snapshot-replay-auth"),
path("add/", AddView.as_view(), name="add"),
# ``query_string=True`` preserves the ``?next=…`` param that Django's
# auth/login mixins append, so e.g. ``UserPassesTestMixin`` redirecting
# an unauthenticated ``/add`` visitor to ``/accounts/login/?next=/add/``
# carries the ``next`` through to ``/admin/login/`` and lands them at
# ``/add/`` after login instead of the admin homepage.
path("accounts/login/", RedirectView.as_view(url="/admin/login/", query_string=True)),
path("accounts/logout/", RedirectView.as_view(url="/admin/logout/", query_string=True)),
path("accounts/", include("django.contrib.auth.urls")),
# The disabled-mode login redirect preserves Django's ``?next=…`` query.
*account_urlpatterns,
path("progress.json", live_progress_view, name="live_progress"),
path("admin/", archivebox_admin.urls),
path("api/", include("archivebox.api.urls"), name="api"),
+64 -78
View File
@@ -14,9 +14,7 @@
{% block branding %}<h1>ArchiveBox Admin</h1>{% endblock %}
</a>
<br/>
{% block usertools %}
{% endblock %}
{% block usertools %}{% endblock %}
{% block nav-global %}{% endblock %}
{% block content_title %}
@@ -33,6 +31,45 @@
{% block breadcrumbs %}{% endblock %}
{% block content %}
{% if first_admin_setup %}
<div id="content-main">
{% if form.errors %}
<p class="errornote">{% trans "Please correct the errors below." %}</p>
{% endif %}
<form action="{{ app_path }}" method="post" id="first-admin-form">{% csrf_token %}
<input type="hidden" name="create_first_admin" value="1">
<div class="form-row" style="gap: 0;">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password1.errors }}
{{ form.password1.label_tag }} {{ form.password1 }}
{% if form.password1.help_text %}<div class="help">{{ form.password1.help_text|safe }}</div>{% endif %}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password2.errors }}
{{ form.password2.label_tag }} {{ form.password2 }}
{% if form.password2.help_text %}<div class="help">{{ form.password2.help_text|safe }}</div>{% endif %}
</div>
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Create admin and continue' %}">
</div>
</form>
{% if allauth_enabled %}
<p><a href="/accounts/login/?next={{ next|default:'/admin/'|urlencode }}">Use a configured identity provider instead</a></p>
{% endif %}
</div>
{% elif allauth_enabled %}
<script>
// Keep Django's destination while handing authentication to allauth.
var next = "{{ next|default:"/admin/"|escapejs }}";
window.location.replace("/accounts/login/?next=" + encodeURIComponent(next));
</script>
<noscript>
<p><a href="/accounts/login/?next={{ next|default:'/admin/'|urlencode }}">Click here to log in</a></p>
</noscript>
{% else %}
{% if form.errors and not form.non_field_errors %}
<p class="errornote">
{% if form.errors.items|length == 1 %}{% trans "Please correct the error below." %}{% else %}{% trans "Please correct the errors below." %}{% endif %}
@@ -41,85 +78,34 @@
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="errornote">
{{ error }}
</p>
<p class="errornote">{{ error }}</p>
{% endfor %}
{% endif %}
<div id="content-main">
{% if user.is_authenticated %}
<p class="errornote">
{% blocktrans trimmed %}
You are authenticated as {{ username }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktrans %}
</p>
{% endif %}
{% if first_admin_setup %}
<form action="{{ app_path }}" method="post" id="first-admin-form">{% csrf_token %}
<input type="hidden" name="create_first_admin" value="1">
<div class="form-row" style="gap: 0;">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password1.errors }}
{{ form.password1.label_tag }} {{ form.password1 }}
{% if form.password1.help_text %}<div class="help">{{ form.password1.help_text|safe }}</div>{% endif %}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password2.errors }}
{{ form.password2.label_tag }} {{ form.password2 }}
{% if form.password2.help_text %}<div class="help">{{ form.password2.help_text|safe }}</div>{% endif %}
</div>
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Create admin and continue' %}">
</div>
</form>
{% else %}
{% if user.is_authenticated %}
<p class="errornote">
{% blocktrans trimmed %}
You are authenticated as {{ username }}, but are not authorized to
access this page. Would you like to login to a different account?
{% endblocktrans %}
</p>
{% endif %}
<br/>
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
<div class="form-row" style="gap: 0;">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password.errors }}
{{ form.password.label_tag }} {{ form.password }}
<input type="hidden" name="next" value="{{ next }}">
</div>
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Log in' %}">
</div>
</form>
{% endif %}
{% if not first_admin_setup %}
<center>
<br/><br/>
<hr/>
<br/>
To create a new admin user or reset a password, run:<br/>
<pre>
# Docker Compose
docker compose exec archivebox archivebox manage createsuperuser --username &lt;username&gt;
docker compose exec archivebox archivebox manage changepassword &lt;username&gt;
# Native installs (run inside your data folder)
cd data/
archivebox manage createsuperuser --username &lt;username&gt;
archivebox manage changepassword &lt;username&gt;
</pre>
<pre>
</pre>
<br/>
</center>
{% endif %}
<form action="{{ app_path }}" method="post" id="login-form">{% csrf_token %}
<div class="form-row" style="gap: 0;">
{{ form.username.errors }}
{{ form.username.label_tag }} {{ form.username }}
</div>
<div class="form-row" style="gap: 0;">
{{ form.password.errors }}
{{ form.password.label_tag }} {{ form.password }}
<input type="hidden" name="next" value="{{ next }}">
</div>
<div class="submit-row" style="border: none;">
<label>&nbsp;</label><input type="submit" value="{% trans 'Log in' %}">
</div>
</form>
</div>
{% endif %}
{% endblock %}
+13 -7
View File
@@ -34,19 +34,25 @@
<a href="{% url 'admin:password_change' %}" class="navbar-item navbar-account" title="Change your account password">Account</a>
<span class="navbar-separator navbar-account-separator" aria-hidden="true">/</span>
{% endif %}
<form method="post" action="{% url 'admin:logout' %}" class="navbar-logout-form">
{% csrf_token %}
<button type="submit" class="navbar-item navbar-logout">{% trans 'Log out' %}</button>
</form>
<form id="logout-form" method="post" action="{% url 'admin:logout' %}" style="display:inline">{% csrf_token %}<button type="submit" style="background:none;border:none;padding:0;color:inherit;cursor:pointer;font:inherit">{% trans 'Log out' %}</button></form>
{% endblock %}
{% elif request.COOKIES.archivebox_admin_logged_in == "1" %}
{% comment %}
Authenticated on the admin host but the session cookie is admin-host-
scoped (security boundary — web.* must NEVER see the session).
The hint cookie is the only signal that crosses, so link back to the
admin host where the session and CSRF cookie are available.
The hint cookie is the only signal that crosses, so we render the
logged-out state's `Account` / `Log out` links pointing at admin host
so the user can still reach those pages from web.*.
NOTE: A cross-host POST+CSRF logout form cannot work here — the CSRF
token generated in the web.* context will never match the admin-host-
scoped CSRF cookie, causing a 403. Django's LogoutView also rejects
GET requests (Django 6+), so we cannot simply link to /admin/logout/.
Instead, redirect the user to the admin host where their session lives;
they can log out from there using the real, same-host logout form.
{% endcomment %}
<a href="/admin/" class="navbar-item navbar-account">Account</a>
<a href="/admin/password_change/" title="Change your account password">Account</a> /
<a href="/admin/" title="Go to admin to log out">{% trans 'Log out' %}</a>
{% else %}
<a href="{% url 'admin:login' %}" class="navbar-item navbar-login">{% trans 'Log in' %}</a>
{% endif %}
+15 -6
View File
@@ -1,26 +1,27 @@
"""archivebox/tests/conftest.py - Pytest fixtures for CLI tests."""
import os
import ctypes
import json
import os
import re
import secrets
import signal
import select
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
import shutil
import ctypes
from collections.abc import Callable
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from importlib.util import find_spec
from pathlib import Path
from threading import Event, Thread
from types import SimpleNamespace
from typing import Any
from collections.abc import Callable
import psutil
import pytest
@@ -35,6 +36,11 @@ SESSION_DATA_DIR = Path(
os.environ["ARCHIVEBOX_PYTEST_SESSION_DATA_DIR"] = str(SESSION_DATA_DIR)
os.environ["DATA_DIR"] = str(SESSION_DATA_DIR)
# When the optional dependency is present, exercise the opt-in auth stack in
# the normal test process. The disabled-by-default contract is verified in a
# clean subprocess by test_allauth_config.py.
if find_spec("allauth") is not None:
os.environ.setdefault("ALLAUTH_ENABLED", "true")
(SESSION_DATA_DIR / "tests").mkdir(parents=True, exist_ok=True)
os.chdir(SESSION_DATA_DIR)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "archivebox.core.settings")
@@ -1153,6 +1159,7 @@ def run_python_cwd(
input=script,
capture_output=True,
text=True,
check=False,
cwd=cwd,
env=base_env,
timeout=timeout,
@@ -1355,9 +1362,10 @@ def get_http_response(
def make_latest_schedule_due(cwd: Path) -> None:
from django.utils import timezone
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.tests.test_orm_helpers import use_archivebox_db
from django.utils import timezone
with use_archivebox_db(cwd):
schedule = CrawlSchedule.objects.order_by("-created_at").select_related("template").first()
@@ -1653,6 +1661,7 @@ def resolve_abxpkg_binary_env(
command,
capture_output=True,
text=True,
check=False,
env=command_env,
)
assert result.returncode == 0, result.stderr or result.stdout
+73
View File
@@ -0,0 +1,73 @@
import json
import os
import subprocess
import sys
import pytest
from pydantic import ValidationError
def test_allauth_config_defaults():
from archivebox.config.allauth import AllauthConfig
cfg = AllauthConfig()
assert cfg.ALLAUTH_ENABLED is False
assert cfg.SOCIALACCOUNT_ENABLED is False
assert cfg.REGISTRATION_ENABLED is True
assert cfg.REGISTRATION_MODE == "open"
assert cfg.EMAIL_VERIFICATION == "none"
assert cfg.DEFAULT_USER_PERMISSIONS == "none"
def test_allauth_disabled_does_not_activate_django(tmp_path):
env = os.environ.copy()
env["ALLAUTH_ENABLED"] = "false"
env["DATA_DIR"] = str(tmp_path)
result = subprocess.run(
[
sys.executable,
"-c",
"""
import django
import json
django.setup()
from django.conf import settings
from django.urls import resolve
login_match = resolve('/accounts/login/')
route_uses_allauth = login_match.url_name == 'account_login'
print(json.dumps({
'app_enabled': 'allauth.account' in settings.INSTALLED_APPS,
'backend_enabled': 'allauth.account.auth_backends.AuthenticationBackend' in settings.AUTHENTICATION_BACKENDS,
'route_uses_allauth': route_uses_allauth,
}))
""",
],
cwd=tmp_path,
env=env,
check=True,
capture_output=True,
text=True,
)
state = json.loads(result.stdout.splitlines()[-1])
assert state == {"app_enabled": False, "backend_enabled": False, "route_uses_allauth": False}
def test_allauth_config_from_env(monkeypatch):
monkeypatch.setenv("ALLAUTH_ENABLED", "true")
monkeypatch.setenv("REGISTRATION_MODE", "approval")
from archivebox.config.common import ArchiveBoxSourceSettings
cfg = ArchiveBoxSourceSettings()
assert cfg.ALLAUTH_ENABLED is True
assert cfg.REGISTRATION_MODE == "approval"
def test_registration_mode_validation():
from archivebox.config.allauth import AllauthConfig
with pytest.raises(ValidationError):
AllauthConfig(REGISTRATION_MODE="invalid")
@@ -0,0 +1,168 @@
import pytest
# =============================================================================
# End-to-end integration tests
# =============================================================================
@pytest.mark.django_db
def test_login_page_returns_200(client):
response = client.get("/accounts/login/")
assert response.status_code == 200
assert b"Log in" in response.content
@pytest.mark.django_db
def test_signup_page_returns_200(client, settings, monkeypatch):
settings.ACCOUNT_ADAPTER = "archivebox.auth.adapters.ArchiveBoxAccountAdapter"
import archivebox.auth.adapters as m
monkeypatch.setattr(m, "_get_registration_enabled", lambda: True)
monkeypatch.setattr(m, "_get_registration_mode", lambda: "open")
response = client.get("/accounts/signup/")
assert response.status_code == 200
@pytest.mark.django_db
def test_admin_login_redirects_to_allauth(client, django_user_model):
django_user_model.objects.create_superuser(username="admin", email="admin@example.com", password="testpassword123")
response = client.get("/admin/login/")
assert response.status_code == 200
content = response.content.decode()
assert "/accounts/login/" in content
@pytest.mark.django_db
def test_admin_login_preserves_first_admin_setup(client):
response = client.get("/admin/login/")
assert response.status_code == 200
content = response.content.decode()
assert 'id="first-admin-form"' in content
assert "Create admin and continue" in content
@pytest.mark.django_db
def test_email_password_login_works(client, django_user_model, monkeypatch):
from archivebox.config.common import get_config
from archivebox.core.routes_util import get_admin_host
config = get_config()
admin_host = get_admin_host(config=config)
user = django_user_model.objects.create_user(
username="logintest",
email="logintest@example.com",
password="testpassword123",
)
from allauth.account.models import EmailAddress
EmailAddress.objects.create(
user=user,
email="logintest@example.com",
primary=True,
verified=True,
)
response = client.post(
"/accounts/login/",
{
"login": "logintest@example.com",
"password": "testpassword123",
},
HTTP_HOST=admin_host,
follow=True,
)
assert response.status_code == 200
assert response.wsgi_request.user.is_authenticated
@pytest.mark.django_db
def test_registration_mode_approval_creates_inactive_user(client, settings, monkeypatch):
from archivebox.config.common import get_config
from archivebox.core.routes_util import get_admin_host
settings.ACCOUNT_ADAPTER = "archivebox.auth.adapters.ArchiveBoxAccountAdapter"
import archivebox.auth.adapters as m
monkeypatch.setattr(m, "_get_registration_enabled", lambda: True)
monkeypatch.setattr(m, "_get_registration_mode", lambda: "approval")
client.post(
"/accounts/signup/",
{
"email": "newuser@example.com",
"password1": "ComplexPass999!",
"password2": "ComplexPass999!",
},
HTTP_HOST=get_admin_host(config=get_config()),
)
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.filter(email="newuser@example.com").first()
assert user is not None
assert user.is_active is False
# =============================================================================
# Adapter unit tests
# =============================================================================
@pytest.mark.django_db
def test_account_adapter_closed_when_registration_disabled(monkeypatch):
import archivebox.auth.adapters as m
monkeypatch.setattr(m, "_get_registration_enabled", lambda: False)
from django.test import RequestFactory
from archivebox.auth.adapters import ArchiveBoxAccountAdapter
adapter = ArchiveBoxAccountAdapter()
request = RequestFactory().get("/accounts/signup/")
assert adapter.is_open_for_signup(request) is False
@pytest.mark.django_db
def test_account_adapter_open_by_default(monkeypatch):
import archivebox.auth.adapters as m
monkeypatch.setattr(m, "_get_registration_enabled", lambda: True)
monkeypatch.setattr(m, "_get_registration_mode", lambda: "open")
from django.test import RequestFactory
from archivebox.auth.adapters import ArchiveBoxAccountAdapter
adapter = ArchiveBoxAccountAdapter()
request = RequestFactory().get("/accounts/signup/")
assert adapter.is_open_for_signup(request) is True
@pytest.mark.django_db
def test_signal_assigns_admin_permissions(monkeypatch):
from django.contrib.auth import get_user_model
import archivebox.auth.signals as s
from archivebox.auth.signals import _apply_default_permissions
User = get_user_model()
user = User.objects.create_user(username="testuser100", password="pass")
monkeypatch.setattr(s, "_get_default_permissions", lambda: "admin")
_apply_default_permissions(user)
user.refresh_from_db()
assert user.is_superuser is True
assert user.is_staff is True
@pytest.mark.django_db
def test_signal_none_permissions_adds_no_group(monkeypatch):
from django.contrib.auth import get_user_model
import archivebox.auth.signals as s
from archivebox.auth.signals import _apply_default_permissions
User = get_user_model()
user = User.objects.create_user(username="testuser101", password="pass")
monkeypatch.setattr(s, "_get_default_permissions", lambda: "none")
_apply_default_permissions(user)
assert user.groups.count() == 0
user.refresh_from_db()
assert user.is_superuser is False
+4 -1
View File
@@ -102,6 +102,9 @@ ldap = [
"python-ldap>=3.4.3",
"django-auth-ldap>=4.1.0",
]
allauth = [
"django-allauth[socialaccount]>=65.5.0",
]
debug = [
# packages needed for running with DEBUG=True
"django-debug-toolbar>=4.4.6",
@@ -111,7 +114,7 @@ debug = [
"django-autotyping>=0.5.1",
]
all = [
"archivebox[sonic,ldap,debug]"
"archivebox[sonic,ldap,debug,allauth]"
]
[dependency-groups]
Generated
+1008 -866
View File
File diff suppressed because it is too large. Load diff