Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c680aa58b2 | ||
|
|
a32145b251 | ||
|
|
3670e854b8 | ||
|
|
392f7b8996 | ||
|
|
5bd8a8ad9b | ||
|
|
42731eff41 | ||
|
|
8b1b9eff61 | ||
|
|
63689f848a |
No files matched your search
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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> </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> </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>
|
||||
|
|
||||
<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> </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> </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> </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> </label><input type="submit" value="{% trans 'Continue' %}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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> </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> </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> </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 <username>
|
||||
docker compose exec archivebox archivebox manage changepassword <username>
|
||||
|
||||
# Native installs (run inside your data folder)
|
||||
cd data/
|
||||
archivebox manage createsuperuser --username <username>
|
||||
archivebox manage changepassword <username>
|
||||
</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> </label><input type="submit" value="{% trans 'Log in' %}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user