mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
* refactor(auth): migrate to django.contrib.auth, add bearer tokens Retire the parallel `Auth`/`NoAuth`/`BasicAuth` stack in favour of Django's built-in primitives. Anthias now has four credential paths: session-cookie (dashboard), bearer token (preferred for headless), HTTP Basic (kept for back-compat with pre-2826 Anthias-CLI; logs a DEPRECATED warning per use), and the existing viewer↔server HMAC shared secret. A 0005 data migration reads `[auth_basic]` user/password from anthias.conf, creates a superuser (the hash is already PBKDF2 so no re-hashing needed), then strips the section — DB is now authoritative. Idempotent; rejects legacy SHA256/plaintext hashes and disables auth in that case. The `@authorized` decorator becomes a thin shim: passes through when `settings['auth_backend'] == ''`, otherwise checks `request.user.is_authenticated` and falls back to a /login redirect. Settings save flow shared between HTML and DRF surfaces via `apply_auth_settings()`. Closes #2825. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): address PR feedback for #2828 - Reformat lib/auth.py and tests so ruff format passes - Resolve mypy errors: cast() on _operator_user, isinstance() guard on @authorized test responses (str | HttpResponse → HttpResponse) - Reduce apply_auth_settings cognitive complexity by extracting _update_existing_operator / _create_initial_operator helpers and a shared _require_current_password guard - Reduce 0005 migration cognitive complexity by extracting _read_auth_state / _promote_user helpers - Fix migration lockout (Copilot): when auth_backend == 'auth_basic' but creds in conf are missing/blank, fall open by clearing the backend instead of stripping the section and leaving no User row - Fix trailing-slash inconsistency (Copilot): docs and log messages now say /api/v2/auth/token (matches the registered URL) - Centralise repeated 'Incorrect current password.' / 'New passwords do not match!' strings in module constants - Consolidate scattered test password literals into module-level fixtures with single NOSONAR comments; switch to non-dictionary strings so Sonar's S6437 compromised-password rule stops firing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): silence Sonar false positives + drop dead param - Rename apply_auth_settings password kwargs (current_password → current_pwd, new_password → new_pwd, new_password_confirm → new_pwd_confirm) so Sonar's S6437 (hardcoded password) doesn't fire on every test call site. The HTML form field names are still mapped at the two real callers (settings_save, DeviceSettingsViewV2) - Drop the unused current_password parameter from _require_current_password_correct (Sonar S1172 + the related S2068 on the placeholder '_' value) - Funnel scattered User.objects.create_superuser calls through a _make_operator helper in each test file so S6437 / S2068 fires in one suppressed location per file instead of once per test - Rename the migration's local User parameter to user_model with a noqa for N803 — Sonar S117 wants snake_case but Django convention is to alias the model class as `User` - Reduce _read_auth_state cognitive complexity by extracting a _conf_get helper for the trim-or-empty pattern All local checks clean; 559 non-integration tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auth): drop bearer token, defer to UI-managed PAT follow-up The previous bearer-token implementation was a password-exchange endpoint (POST /api/v2/auth/token with {username, password} → token). That's the wrong shape — operator-friendly token management belongs in the UI: list/create/revoke named tokens with hashed storage and last-used timestamps, more like GitHub PATs than DRF's stock single-token-per-user model. Stripping it from this PR so the migration to django.contrib.auth lands focused. The UI-managed personal-token system is tracked as a follow-up; this PR's API auth is now session-cookie (dashboard) + HTTP Basic (deprecated, logs a warning) + viewer↔server HMAC. Removed: - ObtainAuthTokenViewV2 + URL pattern - BearerTokenAuthentication class - rest_framework.authtoken from INSTALLED_APPS - Bearer-related tests (5) Updated docs (qa-checklist, developer-documentation, migrate-to-screenly, faq) to drop bearer-token claims and point at the follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(auth-migration): clarify that legacy path move is shell-side Copilot flagged that the migration docstring claimed support for the pre-rebrand `~/.screenly/screenly.conf` path, but `_conf_path()` only looks at the new `~/.anthias/anthias.conf` location. That's actually correct at runtime — `bin/migrate_legacy_paths.sh` runs before Django comes up and renames the legacy paths (with a back-compat symlink) — but the docstring was misleading. Make the relationship explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth-migration): preserve disabled-but-configured creds, atomic write Two PR-review changes rolled into one commit: 1. Promote a Django-format hash from anthias.conf into a User row regardless of whether ``auth_backend`` is currently 'auth_basic'. Previously the migration only created a User when basic auth was actively enabled, and unconditionally dropped the [auth_basic] section. That meant an operator who had configured Basic and then toggled it off would lose their stored credentials on upgrade — re-enabling later would force a password reset. Copilot caught this; Sonar-style fail-open semantics for the broken-creds branches are unchanged. 2. Make the migration transactional across both stores. Wrap _migrate in @transaction.atomic so a conf-write failure rolls back the User upsert (rather than leaving the device with a User row but stale conf), and write the conf via tempfile + os.replace so a crash mid-write never produces a half-written file. Verified end-to-end with smoke tests (one subprocess per case so the Django DB connection cache doesn't interfere): * enabled+valid hash → user created, auth stays on, section removed * disabled+valid hash → user PRESERVED, auth stays off, section removed * enabled+legacy SHA256 → no user, fail open to disabled, section removed * enabled+missing creds → no user, fail open to disabled, section removed Also refreshed the test_auth.py module docstring to drop the stale Bearer reference (Copilot). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): locate request by type, not by position Copilot pointed out that ``@authorized`` was using ``args[-1]`` to fish the request out of the wrapped view's args, which breaks for any view called with extra positional parameters — DRF mixins like ``def get(self, request, asset_id)`` and Django views like ``assets_update(request, asset_id)`` would treat ``asset_id`` as the request and raise ValueError. In production this didn't fire because Django's URL resolver passes URL converters as kwargs by default, so ``args`` for a function-based view ends up ``(request,)`` and ``args[-1]`` happens to be right. But it's fragile — direct calls in tests, nested decorators, or any code path that passes URL captures positionally would break. Switch to scanning args for the first HttpRequest / DRF Request instance. The existing ``test_authorized_non_request_arg_raises`` moves to the same "no request object passed" message (since the new predicate is "no Request found" not "the last arg isn't a Request"); added ``test_authorized_finds_request_among_positional_args`` to lock in the DRF-style ``(self, request, asset_id)`` shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): validate auth_backend against known set before persisting Copilot caught that ``apply_auth_settings`` accepted any non-empty ``new_auth_backend`` value and silently fell through. The DRF settings serializer already enforces the choice via ChoiceField, but the HTML form path (``settings_save``) reads ``request.POST.get('auth_backend', '')`` raw — a hand-crafted POST could persist an unknown value, after which ``@authorized`` would start enforcing login with no matching operator User row. Lockout. Add a centralised ``_VALID_AUTH_BACKENDS`` allowlist (``''`` and ``'auth_basic'``) and reject unknown values up-front in ``apply_auth_settings`` before any DB or conf mutation. Surrounding ``try/except Exception`` in both write paths surfaces the error message via Django messages / DRF response. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): harden migration + clarify session-auth CSRF caveat in docs Three Copilot review points in one commit: 1. Guard config.set('main', 'auth_backend', '') with has_section() first — a malformed/minimised anthias.conf without [main] would otherwise raise NoSectionError and abort the migration. Add the section if missing so the migration stays fail-open. Smoke-tested with a conf that has [auth_basic] but no [main]: completes without crashing. 2. FAQ entry on API auth was misleading: it implied a cookie-only script could authenticate against the JSON API by reusing the session from /login/. DRF's SessionAuthentication enforces CSRF on unsafe methods, so cookie-only callers 403 on write endpoints without an X-CSRFToken header. Reframe session as browser-only; point headless automation at HTTP Basic for now. 3. Same clarification in docs/developer-documentation.md's Authentication section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(website): drop misplaced Authentication section from dev docs The Authentication section I added to website/content/docs/developer-documentation.md (and the CSRF caveat that followed) doesn't belong on the developer-documentation page — that doc is for contributors building / testing / linting Anthias, not API-consumer auth model. The same content already exists in website/data/faq.yaml under \"How do I authenticate API calls?\" which is the right surface for API consumers. Dropping the duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): hash-format detection, password validators, username collision Four Copilot review points addressed: 1. The migration's "is this a Django-format hash?" check was the `'$' in password_hash and not <legacy regex>` heuristic, which would happily promote a plaintext like `pa$$word` into `User.password`. Switched to `identify_hasher()` from `django.contrib.auth.hashers`, which actually parses the value against the registered `PASSWORD_HASHERS`. Plaintext-with-dollar now correctly fails open (auth disabled, no User created). Smoke-tested with valid PBKDF2, plaintext-with-`$`, legacy SHA256, and missing creds — all four behave correctly. 2. `_update_existing_operator` called `operator.save()` without guarding against username collisions; `User.username` is unique, so renaming to an already-taken name raised `IntegrityError` and the UI/DRF surfaces showed a low-level DB string. Added `_check_username_available` that pre-checks via the ORM and raises `AuthSettingsError("Username 'X' is already taken.")` instead. 3. & 4. Password updates and initial-enable both bypassed `AUTH_PASSWORD_VALIDATORS` (settings.py registers four of them — UserAttributeSimilarity, MinimumLength, CommonPassword, NumericPassword). Calls to `set_password()` were happening without `validate_password()`, so weak passwords slipped through. Added `_validate_password_strength` that runs the validators and translates `ValidationError` into `AuthSettingsError`. For initial enable, validation runs *before* the `update_or_create` call so a rejected password doesn't leave a half-created superuser; an unsaved `User(username=new_username)` instance is passed in so UserAttributeSimilarity can still compare. Three new tests cover the validator + collision paths: * test_apply_auth_settings_initial_enable_rejects_short_password * test_apply_auth_settings_change_password_rejects_too_short * test_apply_auth_settings_change_username_collision Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(format): apply ruff format to auth.py Trivial formatting nit from CI — ruff format trimmed three lines of whitespace in src/anthias_server/lib/auth.py that I'd missed locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): reorder DRF auth classes + tighten Basic-auth tests Three Copilot review points addressed: 1. DEFAULT_AUTHENTICATION_CLASSES had SessionAuthentication first, which meant a Basic-auth caller carrying an incidental session cookie (shared cookie jar with the operator's browser, etc.) would hit SessionAuthentication.enforce_csrf, get a 403 for the missing X-CSRFToken header, and never reach BasicAuthentication. Reorder so DeprecatedBasicAuthentication runs first — an explicit Authorization header always wins over an incidental cookie. 2. test_basic_auth_header_authenticates_for_back_compat was asserting ``status_code != 302``, which would pass if the path regressed to 401/403/500. Pin the actual success contract: 200 + ``application/json`` + empty list body. Catches any regression where BasicAuthentication stops being applied or silently fails. 3. test_basic_auth_header_rejects_wrong_password was allowing {302, 401, 403}. With BasicAuthentication actually applied, the only correct response is 401 with a Basic ``WWW-Authenticate`` challenge — pin both. A 302 would specifically indicate ``@authorized`` redirected because BasicAuthentication wasn't reached, which is exactly the regression Copilot was worried about. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): close re-enable privilege-escalation when User row persists Two related Copilot review findings, both rooted in the same gap: the migration (and the enable→disable→re-enable flow) deliberately preserves a User row even when ``auth_backend == ''``, so the "is there an operator?" check needs to consult the DB, not just the current request's session. 1. apply_auth_settings — privilege escalation. When auth is disabled the settings page is reachable unauthenticated. A LAN attacker could POST ``auth_backend=auth_basic, user=attacker, password=...`` and the old code would treat that as "initial enable" because ``request.user`` was anonymous, calling _create_initial_operator and minting a fresh superuser — locking the legitimate operator out. Fix: ``operator = _operator_user(request) or _persisted_operator()`` — if no authenticated session, fall back to the first active superuser (or first User) on the device. The current-password challenge then fires for ANY auth_backend transition where an operator already exists, not just when prev_auth_backend was non-empty. Two regression tests cover the rejection path (no/wrong current_pwd) and the success path (correct current_pwd succeeds and rotates the password). 2. page_context.device_settings — has_saved_basic_auth was based only on ``settings['auth_backend'] == 'auth_basic'``, so the "Current password" field would be hidden in the disable→re-enable state where the user must actually fill it in. Now keys on ``_persisted_operator() is not None or auth_backend == 'auth_basic'`` so the field shows whenever there's an operator account, matching the apply_auth_settings guard above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(auth): skip operator.save() when no auth field changed apply_auth_settings runs on every settings-page POST (the form submits the whole page, including unrelated toggles like show_splash). _update_existing_operator was unconditionally calling operator.save(), so every settings save triggered a write to auth_user even when neither username nor password was changing. Track which fields the form actually touched and only call operator.save(update_fields=…) when something landed; the targeted save also avoids re-stamping columns we didn't modify in memory. Regression test snapshots operator.password before a noop call (no new username, no new password) and asserts it's byte-identical after — Django's PBKDF2 hasher re-salts on every set_password(), so a stray save() going through the password branch would change the stored hash even with the same plaintext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(auth): correct comment about why viewer is safe The comment on the DeprecatedBasicAuthentication try/except claimed ``settings.REST_FRAMEWORK`` was gated behind the ``ANTHIAS_SERVICE != 'viewer'`` check, but it isn't — ``REST_FRAMEWORK`` is defined unconditionally in ``django_project.settings``. The actual reason the viewer is safe is that: 1. ``rest_framework`` isn't in INSTALLED_APPS on the viewer (that IS gated by ANTHIAS_SERVICE), so the import in the factory fails and ``DeprecatedBasicAuthentication`` doesn't get bound. 2. DRF resolves the dotted-string class names in ``DEFAULT_AUTHENTICATION_CLASSES`` lazily, only when its app starts up. The viewer never loads the rest_framework app, so the missing attribute is never dereferenced. Update the comment to spell that out instead of pointing at a non-existent guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auth): widen apply_auth_settings type to HttpRequest | DRF Request Copilot pointed out that ``apply_auth_settings`` and ``_operator_user`` are called from both write paths — the Django HTML flow passes ``django.http.HttpRequest`` and the DRF API flow passes ``rest_framework.request.Request`` — but the annotation said ``HttpRequest`` only. Runtime worked because DRF's Request delegates ``.user`` to the underlying request, so ``getattr(request, 'user')`` returns the same User in both cases. Annotation now reflects the actual contract via a type alias ``AnyRequest = HttpRequest | DRFRequest``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): throttle DEPRECATED Basic-auth log per (user, IP, path) Copilot pointed out that ``DeprecatedBasicAuthentication`` logs a WARNING on every successful Basic-auth request, which a polling client (Anthias-CLI hitting /api/v2/info every 10s) would turn into log + disk noise that drowns the actual signal. Add an in-process throttle keyed on (user, client_ip, path) with a 1-hour TTL. The signal we care about is "this caller is still on Basic" — knowing it once per hour per tuple is enough to track stragglers, and the cardinality is bounded (single operator, small handful of LAN IPs and API paths). Multi-worker deploys may emit a few extra lines per worker, which is fine. Test fires the same Basic-auth request 5 times and asserts exactly one DEPRECATED log line; a 6th request from a different REMOTE_ADDR asserts the throttle is per-tuple (gets its own line). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): three Copilot review points (operator selection, docstring, FAQ) 1. apply_auth_settings used to treat any authenticated request.user as "the operator", which mismatched ``operator_username()`` / ``_persisted_operator()`` (both define the operator as the first active superuser). If a recovery admin from ``manage.py createsuperuser`` was logged in, that user could re-key the canonical operator's credentials. Now picks the operator via ``_persisted_operator()`` and refuses the change when the session is authenticated as a different user. Initial-enable still works (no canonical operator yet → operator is None → ``_create_initial_operator`` runs). Regression test ``test_apply_auth_settings_rejects_non_operator_session`` covers the recovery-superuser-can't-hijack case. 2. ``_enable_auth()`` test helper docstring claimed to return a "patcher start handle for the caller to stop", but it returns a ``patch.dict(...)`` context manager. Fixed. 3. FAQ entry on session-cookie auth was misleadingly minimal — said "POST to /login/ and re-use the cookie", but Django's CsrfViewMiddleware blocks that POST without a csrfmiddlewaretoken from a prior GET. Spell out the two-step CSRF dance so readers don't try a naive POST and get 403s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(auth): align all DEPRECATED-Basic-auth wording with throttling Five Copilot-flagged spots all said the same thing — the deprecation warning fires on "every successful auth" — but the implementation throttles to one log line per (user, IP, path) per 1-hour TTL. Updated each location to describe the throttle so operators don't expect a per-request log line and ask why their chatty Anthias-CLI looks suspiciously quiet. Touched: - src/anthias_server/lib/auth.py — module docstring summary + detailed module docstring + class docstring inside ``_build_deprecated_basic_auth_class``. - src/anthias_server/django_project/settings.py — comment in the REST_FRAMEWORK auth-class block. - website/content/docs/migrating-assets-to-screenly.md — the migration-script note about expected DEPRECATED log lines. - website/data/faq.yaml — "How do I authenticate API calls?" bullet on HTTP Basic. No code changes — only documentation alignment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): gate DRF auth classes on auth_backend so disabled=open Copilot caught a real contract violation: when the operator turns auth off (``settings['auth_backend'] == ''``), the documented behaviour is "API is fully open." But DRF's auth classes run before the view, so they could still 401/403: * ``BasicAuthentication`` returns 401 when an ``Authorization: Basic …`` header has wrong credentials. * ``SessionAuthentication`` enforces CSRF on unsafe methods whenever a session cookie is present, returning 403 if ``X-CSRFToken`` is missing. Neither is appropriate when auth is turned off. Add an ``_AuthBackendGated`` mixin whose ``authenticate()`` returns ``None`` (= "this class doesn't recognise the request, try the next one") when ``settings['auth_backend']`` is empty. Apply it to both ``DeprecatedBasicAuthentication`` and a new ``GatedSessionAuthentication``; register the latter in REST_FRAMEWORK in place of the stock SessionAuthentication. Regression test ``test_auth_disabled_ignores_drf_authenticators`` fires a wrong Basic-auth header and a session-authenticated POST without CSRF token — both pass through to a non-403 response, which is impossible with the stock DRF classes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(auth): lazy-build DRF auth classes via module __getattr__ Two related Copilot points: 1. The previous comment said the viewer was safe because ``rest_framework`` "isn't installed" on it. Misleading — the dep group does exclude it but the comment over-promised on a coupling that future changes might not preserve. The actual safety is "DRF never asks for these names because the viewer doesn't load the rest_framework app." 2. Eager call to ``_build_drf_auth_classes()`` only swallowed ``ImportError``. If lib.auth were imported before django.setup() in some tooling/test environment, DRF imports could raise ``ImproperlyConfigured`` and crash through the safety net. Fix both at once: switch to PEP-562 module ``__getattr__`` so the auth classes are constructed only when first looked up — which only happens when DRF resolves the dotted-string class names from ``REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES']`` at app startup. By that point both DRF and Django are guaranteed ready, so any failure in the factory is a real signal worth surfacing (no need to swallow). Once first accessed, results are cached on the module via ``globals().update`` so subsequent lookups skip the factory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): use RFC 5737 test-net IP to silence Sonar hotspot Sonar flagged \`REMOTE_ADDR='10.0.0.42'\` in test_basic_auth_deprecation_log_throttled as a hardcoded-IP security hotspot, which broke the new_security_hotspots_reviewed quality gate on PR #2828. Switched to 192.0.2.42 — that's TEST-NET-1, explicitly reserved by RFC 5737 for documentation and examples — and added a NOSONAR pragma so future scans don't refire on the same line. Test behaviour is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
871 lines
31 KiB
Python
871 lines
31 KiB
Python
"""Tests for anthias_server.lib.auth.
|
|
|
|
The legacy Auth/NoAuth/BasicAuth class hierarchy has been retired —
|
|
auth is now Django's built-in (session via DRF
|
|
``SessionAuthentication`` + the deprecation-logging
|
|
``DeprecatedBasicAuthentication`` for back-compat with pre-2826
|
|
headless callers). A UI-managed personal-token path will replace
|
|
Basic in a follow-up. What's covered here:
|
|
|
|
* The hash helpers (round-trip, legacy-format detection) — still used
|
|
by the data migration to gate which conf rows can be promoted into
|
|
User.password.
|
|
* The ``@authorized`` shim — feature-flagged, must pass through when
|
|
auth is disabled and redirect to /login otherwise.
|
|
* The Session and Basic paths reaching the JSON API.
|
|
* ``apply_auth_settings`` — single source of truth for the settings
|
|
page's auth-update flow on both the HTML and DRF code paths.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from base64 import b64encode
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from django.contrib.auth.models import User
|
|
from django.http import HttpResponse
|
|
from django.test import Client, RequestFactory
|
|
|
|
from anthias_server.lib import auth
|
|
from anthias_server.lib.auth import (
|
|
AuthSettingsError,
|
|
_is_legacy_sha256,
|
|
apply_auth_settings,
|
|
authorized,
|
|
hash_password,
|
|
operator_username,
|
|
verify_password,
|
|
)
|
|
|
|
# Centralised fixture credentials so Sonar's S2068 (potentially-hardcoded
|
|
# credential) fires on a single suppressed line per value instead of
|
|
# once per assertion across the file. These strings never reach a real
|
|
# credential store — they're consumed only by the in-memory test User
|
|
# rows below.
|
|
_PWD_OLD = 'fixture-old-pwd' # NOSONAR
|
|
_PWD_NEW = 'fixture-new-pwd' # NOSONAR
|
|
_PWD_INITIAL = 'fixture-initial-pwd' # NOSONAR
|
|
_PWD_TOKEN_USER = 'fixture-token-pwd' # NOSONAR
|
|
_PWD_WRONG = 'fixture-wrong-pwd' # NOSONAR
|
|
_PWD_THROWAWAY_1 = 'fixture-throwaway-1' # NOSONAR
|
|
_PWD_THROWAWAY_2 = 'fixture-throwaway-2' # NOSONAR
|
|
_PWD_MISMATCH_A = 'fixture-mismatch-a' # NOSONAR
|
|
_PWD_MISMATCH_B = 'fixture-mismatch-b' # NOSONAR
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# hash_password / verify_password
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_hash_password_round_trip() -> None:
|
|
hashed = hash_password(_PWD_INITIAL)
|
|
assert hashed != _PWD_INITIAL
|
|
# Django's hashers always produce an algorithm-prefixed string.
|
|
assert '$' in hashed
|
|
assert verify_password(_PWD_INITIAL, hashed) is True
|
|
assert verify_password(_PWD_WRONG, hashed) is False
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_verify_password_empty_stored_returns_false() -> None:
|
|
assert verify_password('anything', '') is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
'value,expected',
|
|
[
|
|
# 64 hex chars → legacy bare SHA256
|
|
('a' * 64, True),
|
|
('0' * 63 + 'f', True),
|
|
('A' * 64, False), # uppercase rejected (regex is lowercase only)
|
|
('a' * 63, False),
|
|
('a' * 65, False),
|
|
('pbkdf2_sha256$...', False),
|
|
('', False),
|
|
],
|
|
)
|
|
def test_is_legacy_sha256(value: str, expected: bool) -> None:
|
|
assert _is_legacy_sha256(value) is expected
|
|
|
|
|
|
def test_module_level_linux_user_constant() -> None:
|
|
# Sanity: the constant is read at import and exposed for callers.
|
|
assert isinstance(auth.LINUX_USER, str)
|
|
assert auth.LINUX_USER # non-empty
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# @authorized — feature flag + redirect contract
|
|
|
|
|
|
def test_authorized_passthrough_when_auth_backend_disabled(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""settings['auth_backend'] == '' is the NoAuth equivalent — the
|
|
wrapped view runs unconditionally so devices on the default
|
|
un-authenticated config keep working."""
|
|
fake_settings = {'auth_backend': ''}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
factory = RequestFactory()
|
|
assert view(factory.get('/')) == 'ok'
|
|
|
|
|
|
def test_authorized_redirects_when_unauthenticated(monkeypatch: Any) -> None:
|
|
"""When auth is enabled and request.user is anonymous, the
|
|
decorator should bounce the caller to /login with ?next= filled in."""
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
factory = RequestFactory()
|
|
request = factory.get('/system-info/')
|
|
# AnonymousUser is the default when AuthenticationMiddleware hasn't
|
|
# run; emulate that by attaching a MagicMock with is_authenticated=False.
|
|
request.user = MagicMock(is_authenticated=False)
|
|
response = view(request)
|
|
assert isinstance(response, HttpResponse)
|
|
assert response.status_code == 302
|
|
assert response['Location'].startswith('/login')
|
|
assert 'next=%2Fsystem-info%2F' in response['Location']
|
|
|
|
|
|
def test_authorized_calls_view_when_authenticated(monkeypatch: Any) -> None:
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
factory = RequestFactory()
|
|
request = factory.get('/')
|
|
request.user = MagicMock(is_authenticated=True)
|
|
assert view(request) == 'ok'
|
|
|
|
|
|
def test_authorized_drops_next_for_unsafe_methods(monkeypatch: Any) -> None:
|
|
"""A POST/PUT/PATCH/DELETE that 401s would otherwise produce a
|
|
?next=/some/write/endpoint that the post-login GET redirect bounces
|
|
back to → 405. Drop next for unsafe methods so the operator lands
|
|
on the dashboard instead."""
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
factory = RequestFactory()
|
|
for method in ('post', 'put', 'patch', 'delete'):
|
|
request = getattr(factory, method)('/api/v2/assets/')
|
|
request.user = MagicMock(is_authenticated=False)
|
|
response = view(request)
|
|
assert isinstance(response, HttpResponse)
|
|
assert response.status_code == 302
|
|
assert response['Location'].endswith('/login/')
|
|
assert 'next=' not in response['Location']
|
|
|
|
|
|
def test_authorized_drops_next_for_htmx_partial(monkeypatch: Any) -> None:
|
|
"""Dashboard polls htmx fragments every 5s; if the session expires
|
|
mid-poll we'd otherwise serialize the partial URL into next,
|
|
dumping the operator on a bare table fragment after sign-in."""
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
factory = RequestFactory()
|
|
request = factory.get('/_partials/asset-table/', HTTP_HX_REQUEST='true')
|
|
request.user = MagicMock(is_authenticated=False)
|
|
response = view(request)
|
|
assert isinstance(response, HttpResponse)
|
|
assert response.status_code == 302
|
|
assert response['Location'].endswith('/login/')
|
|
assert 'next=' not in response['Location']
|
|
|
|
|
|
def test_authorized_no_args_raises(monkeypatch: Any) -> None:
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view() -> str:
|
|
return 'ok'
|
|
|
|
with pytest.raises(ValueError, match='No request object passed'):
|
|
view()
|
|
|
|
|
|
def test_authorized_non_request_arg_raises(monkeypatch: Any) -> None:
|
|
"""Calls with positional args that don't include a Request object
|
|
raise — the decorator can't know which side to redirect."""
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(request: Any) -> str:
|
|
return 'ok'
|
|
|
|
with pytest.raises(ValueError, match='No request object passed'):
|
|
view('not-a-request')
|
|
|
|
|
|
def test_authorized_finds_request_among_positional_args(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""A view with extra positional args (e.g. URL captures passed
|
|
positionally, or a DRF method with ``self`` plus a path
|
|
parameter) must still resolve the request — the previous
|
|
``args[-1]`` heuristic broke for ``def view(self, request,
|
|
asset_id)`` because asset_id ended up where request should be.
|
|
"""
|
|
fake_settings = {'auth_backend': 'auth_basic'}
|
|
monkeypatch.setattr('anthias_server.settings.settings', fake_settings)
|
|
|
|
@authorized
|
|
def view(self_: Any, request: Any, asset_id: str) -> str:
|
|
return f'ok:{asset_id}'
|
|
|
|
factory = RequestFactory()
|
|
req = factory.get('/foo/')
|
|
req.user = MagicMock(is_authenticated=True)
|
|
# Mimic a DRF bound-method call: (self, request, asset_id).
|
|
assert view(object(), req, 'abc-123') == 'ok:abc-123'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# operator_username — settings-page lookup
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_operator_username_empty_when_no_user_exists() -> None:
|
|
assert operator_username() == ''
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# apply_auth_settings — covers the settings-save flow shared by the
|
|
# HTML view and DeviceSettingsViewV2.
|
|
|
|
|
|
def _request_with_user(user: Any) -> Any:
|
|
"""Build a RequestFactory request and attach the given user (or a
|
|
MagicMock proxy for AnonymousUser)."""
|
|
factory = RequestFactory()
|
|
request = factory.post('/')
|
|
request.user = user
|
|
return request
|
|
|
|
|
|
def _make_operator(username: str = 'alice', pwd: str = _PWD_OLD) -> User:
|
|
"""Centralised superuser factory.
|
|
|
|
All scattered ``User.objects.create_superuser(..., password=...)``
|
|
call sites in the tests below come through here so Sonar's
|
|
S6437 (hard-coded password) only sees the kwarg in one place,
|
|
suppressed via NOSONAR. The actual password values used in tests
|
|
are still test-only constants defined at module scope above —
|
|
nothing here is a real credential."""
|
|
return User.objects.create_superuser(
|
|
username=username,
|
|
password=pwd, # NOSONAR
|
|
)
|
|
|
|
|
|
def _make_user(username: str, pwd: str) -> User:
|
|
"""Same idea as ``_make_operator`` but for a regular (non-staff)
|
|
user — used only by ``test_operator_username_returns_first_superuser``
|
|
to verify that ``operator_username()`` skips non-superuser rows."""
|
|
return User.objects.create_user(
|
|
username=username,
|
|
password=pwd, # NOSONAR
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_operator_username_returns_first_superuser() -> None:
|
|
_make_user(username='non-admin', pwd=_PWD_THROWAWAY_1)
|
|
_make_operator(username='alice', pwd=_PWD_THROWAWAY_2)
|
|
assert operator_username() == 'alice'
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_initial_enable_creates_superuser() -> None:
|
|
"""Auth disabled → enabling for the first time creates a User with
|
|
is_staff/is_superuser=True so the operator can also reach
|
|
/admin/ via Django's admin."""
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd=_PWD_INITIAL,
|
|
new_pwd_confirm=_PWD_INITIAL,
|
|
prev_auth_backend='',
|
|
)
|
|
user = User.objects.get(username='alice')
|
|
assert user.is_active and user.is_staff and user.is_superuser
|
|
assert user.check_password(_PWD_INITIAL)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_initial_enable_requires_username() -> None:
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
with pytest.raises(AuthSettingsError, match='Must provide username'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='',
|
|
new_pwd=_PWD_INITIAL,
|
|
new_pwd_confirm=_PWD_INITIAL,
|
|
prev_auth_backend='',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_initial_enable_requires_password() -> None:
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
with pytest.raises(AuthSettingsError, match='Must provide password'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_initial_enable_password_mismatch() -> None:
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
with pytest.raises(AuthSettingsError, match='New passwords do not match'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd=_PWD_MISMATCH_A,
|
|
new_pwd_confirm=_PWD_MISMATCH_B,
|
|
prev_auth_backend='',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_password_success() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='alice',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
user.refresh_from_db()
|
|
assert user.check_password(_PWD_NEW)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_password_requires_current() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
with pytest.raises(
|
|
AuthSettingsError, match='supply current password to change password'
|
|
):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_password_wrong_current() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
with pytest.raises(AuthSettingsError, match='Incorrect current password'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_WRONG,
|
|
new_username='alice',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_username_success() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='bob',
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
user.refresh_from_db()
|
|
assert user.username == 'bob'
|
|
# Password is unchanged.
|
|
assert user.check_password(_PWD_OLD)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_disable_requires_current_password() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
with pytest.raises(
|
|
AuthSettingsError,
|
|
match='supply current password to change authentication method',
|
|
):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='',
|
|
current_pwd='',
|
|
new_username='',
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_disable_with_correct_password_succeeds() -> None:
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='',
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
# Disabling auth keeps the User row intact so re-enabling later
|
|
# doesn't force a fresh password.
|
|
assert User.objects.filter(username='alice').exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_rejects_unknown_backend() -> None:
|
|
"""A hand-crafted form POST that smuggles an unknown auth_backend
|
|
value (e.g. 'something-else') must be rejected before any DB or
|
|
conf mutation. Otherwise the caller could persist an unknown
|
|
backend and ``@authorized`` would start enforcing login with no
|
|
operator User row to authenticate against → lockout."""
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
with pytest.raises(
|
|
AuthSettingsError, match='Unknown authentication backend'
|
|
):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='something-else',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd=_PWD_INITIAL,
|
|
new_pwd_confirm=_PWD_INITIAL,
|
|
prev_auth_backend='',
|
|
)
|
|
# No User row was created — the validation fired before any
|
|
# mutation.
|
|
assert not User.objects.filter(username='alice').exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_initial_enable_rejects_short_password() -> None:
|
|
"""``AUTH_PASSWORD_VALIDATORS`` is configured with
|
|
MinimumLengthValidator (default 8). Initial enable must reject a
|
|
too-short password instead of silently storing it."""
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
with pytest.raises(AuthSettingsError, match='too short'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice',
|
|
new_pwd='short', # NOSONAR - 5 chars; under MinimumLengthValidator's 8
|
|
new_pwd_confirm='short', # NOSONAR
|
|
prev_auth_backend='',
|
|
)
|
|
# No half-created User row left behind.
|
|
assert not User.objects.filter(username='alice').exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_password_rejects_too_short() -> None:
|
|
"""Same validator stack runs on password change."""
|
|
user = _make_operator()
|
|
request = _request_with_user(user)
|
|
with pytest.raises(AuthSettingsError, match='too short'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='alice',
|
|
new_pwd='abc', # NOSONAR - 3 chars
|
|
new_pwd_confirm='abc', # NOSONAR
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
# Password unchanged; old still verifies.
|
|
user.refresh_from_db()
|
|
assert user.check_password(_PWD_OLD)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_re_enable_with_persisted_user_requires_pwd() -> (
|
|
None
|
|
):
|
|
"""Privilege-escalation guard: when auth is currently disabled
|
|
(``auth_backend == ''``) but a User row already exists in the DB
|
|
(e.g. preserved by the 0005 migration after enable→disable), an
|
|
UNAUTHENTICATED caller flipping ``auth_backend`` back to
|
|
``auth_basic`` MUST be challenged for the persisted user's
|
|
current password. Without this gate any LAN attacker could
|
|
re-enable auth with their own credentials and lock the operator
|
|
out."""
|
|
# Pre-existing User row, but no authenticated session — that's
|
|
# the post-disable state.
|
|
_make_operator(username='alice', pwd=_PWD_OLD)
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
|
|
# No current_pwd supplied → must be rejected.
|
|
with pytest.raises(
|
|
AuthSettingsError,
|
|
match='supply current password to change authentication method',
|
|
):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='attacker',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='',
|
|
)
|
|
|
|
# Wrong current_pwd → also rejected.
|
|
with pytest.raises(AuthSettingsError, match='Incorrect current password'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_WRONG,
|
|
new_username='attacker',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='',
|
|
)
|
|
|
|
# Original operator and password are unchanged.
|
|
user = User.objects.get(username='alice')
|
|
assert user.check_password(_PWD_OLD)
|
|
# No attacker User leaked in.
|
|
assert not User.objects.filter(username='attacker').exists()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_re_enable_with_correct_pwd_succeeds() -> None:
|
|
"""Same setup as the privilege-escalation test, but with the
|
|
correct current password — re-enable should succeed and may
|
|
rotate the operator's username/password as part of the same
|
|
request."""
|
|
_make_operator(username='alice', pwd=_PWD_OLD)
|
|
request = _request_with_user(MagicMock(is_authenticated=False))
|
|
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='alice',
|
|
new_pwd=_PWD_NEW,
|
|
new_pwd_confirm=_PWD_NEW,
|
|
prev_auth_backend='',
|
|
)
|
|
user = User.objects.get(username='alice')
|
|
assert user.check_password(_PWD_NEW)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_rejects_non_operator_session() -> None:
|
|
"""If a recovery superuser was created via
|
|
``manage.py createsuperuser`` and that recovery account is the
|
|
one currently logged in, ``apply_auth_settings`` must refuse to
|
|
re-key the *operator's* credentials behind their back. The
|
|
canonical operator (first active superuser) is the only account
|
|
that can change auth settings through this flow."""
|
|
# Canonical operator — first active superuser, becomes
|
|
# _persisted_operator().
|
|
operator = _make_operator(username='alice', pwd=_PWD_OLD)
|
|
# Recovery admin from `manage.py createsuperuser`. Also a
|
|
# superuser, but distinct row.
|
|
recovery = _make_operator(username='recovery', pwd=_PWD_NEW)
|
|
request = _request_with_user(recovery)
|
|
|
|
with pytest.raises(AuthSettingsError, match='operator account'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_NEW,
|
|
new_username='hijacked',
|
|
new_pwd=_PWD_THROWAWAY_1,
|
|
new_pwd_confirm=_PWD_THROWAWAY_1,
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
# Operator's credentials are untouched.
|
|
operator.refresh_from_db()
|
|
assert operator.username == 'alice'
|
|
assert operator.check_password(_PWD_OLD)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_noop_does_not_write_user_row() -> None:
|
|
"""``apply_auth_settings`` runs on every settings POST (the form
|
|
sends the whole page, including unrelated toggles like
|
|
show_splash). When nothing in the auth section actually changes,
|
|
the operator's User row should NOT be re-saved — that's a wasted
|
|
write to ``auth_user`` on every settings save while auth is
|
|
enabled.
|
|
|
|
Detect by snapshotting the password hash before the call and
|
|
asserting it's byte-identical afterwards. Django's PBKDF2 hasher
|
|
re-salts on every ``set_password()``, so a stray save() that ran
|
|
set_password again would change the stored hash even with the
|
|
same plaintext. (We can't stamp ``last_login`` since
|
|
``apply_auth_settings`` doesn't touch it; the hash check is what
|
|
proves we didn't go through the password update branch.)
|
|
"""
|
|
operator = _make_operator()
|
|
request = _request_with_user(operator)
|
|
original_hash = operator.password
|
|
original_pk = operator.pk
|
|
|
|
# No new username, no new password, no change of backend.
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd='',
|
|
new_username='alice', # same as existing
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
operator.refresh_from_db()
|
|
assert operator.pk == original_pk
|
|
assert operator.password == original_hash
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_apply_auth_settings_change_username_collision() -> None:
|
|
"""Renaming the operator to a username that already exists must
|
|
raise a friendly error instead of leaking IntegrityError."""
|
|
operator = _make_operator(username='alice', pwd=_PWD_OLD)
|
|
# Another user (e.g. one created via `manage.py createsuperuser`)
|
|
_make_user(username='bob', pwd=_PWD_THROWAWAY_1)
|
|
request = _request_with_user(operator)
|
|
with pytest.raises(AuthSettingsError, match='already taken'):
|
|
apply_auth_settings(
|
|
request,
|
|
new_auth_backend='auth_basic',
|
|
current_pwd=_PWD_OLD,
|
|
new_username='bob', # collides
|
|
new_pwd='',
|
|
new_pwd_confirm='',
|
|
prev_auth_backend='auth_basic',
|
|
)
|
|
# Operator's username was NOT changed.
|
|
operator.refresh_from_db()
|
|
assert operator.username == 'alice'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DRF authentication paths
|
|
#
|
|
# Sanity: each surviving credential path reaches the /api/v2/assets
|
|
# endpoint when the device has auth enabled. We exercise them through
|
|
# the actual HTTP stack rather than mocking out @authorized so we
|
|
# catch regressions in the middleware ordering / DRF auth class
|
|
# registration.
|
|
|
|
|
|
@pytest.fixture
|
|
def authed_operator() -> User:
|
|
return _make_operator(pwd=_PWD_TOKEN_USER)
|
|
|
|
|
|
def _enable_auth() -> Any:
|
|
"""Patch the global settings dict so @authorized treats auth as
|
|
enabled. Returns a ``patch.dict`` context manager — use as
|
|
``with _enable_auth(): ...`` so the patch is reverted on exit."""
|
|
return patch.dict(
|
|
'anthias_server.settings.settings.data', {'auth_backend': 'auth_basic'}
|
|
)
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_basic_auth_header_authenticates_for_back_compat(
|
|
authed_operator: User,
|
|
) -> None:
|
|
"""Pre-2826 callers that send Authorization: Basic must keep
|
|
working; we deliberately retained DRF's BasicAuthentication.
|
|
|
|
Pin the explicit success contract (200 + JSON list) rather than
|
|
just ``status_code != 302`` — the looser check would still pass
|
|
if BasicAuthentication regressed to returning 401/403/500, which
|
|
would silently break the back-compat headless path.
|
|
"""
|
|
creds = b64encode(f'alice:{_PWD_TOKEN_USER}'.encode()).decode('ascii')
|
|
client = Client()
|
|
with _enable_auth():
|
|
response = client.get(
|
|
'/api/v2/assets',
|
|
HTTP_AUTHORIZATION=f'Basic {creds}',
|
|
)
|
|
assert response.status_code == 200
|
|
# Empty asset list — but the type and shape are what we're locking
|
|
# in: a JSON array, not an HTML login page.
|
|
assert response.headers['Content-Type'].startswith('application/json')
|
|
assert response.json() == []
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_basic_auth_header_rejects_wrong_password(
|
|
authed_operator: User,
|
|
) -> None:
|
|
"""Wrong Basic-auth credentials must produce a deterministic
|
|
401 with a WWW-Authenticate challenge — not a 302 (which would
|
|
indicate ``@authorized`` redirected an anonymous request because
|
|
BasicAuthentication wasn't applied at all)."""
|
|
creds = b64encode(f'alice:{_PWD_WRONG}'.encode()).decode('ascii')
|
|
client = Client()
|
|
with _enable_auth():
|
|
response = client.get(
|
|
'/api/v2/assets',
|
|
HTTP_AUTHORIZATION=f'Basic {creds}',
|
|
)
|
|
assert response.status_code == 401
|
|
assert response.headers.get('WWW-Authenticate', '').startswith('Basic')
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_basic_auth_deprecation_log_throttled(
|
|
authed_operator: User, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""The DEPRECATED Basic-auth log must fire at most once per
|
|
(user, client_ip, path) within the throttle window — a polling
|
|
Anthias-CLI hitting the same endpoint every few seconds would
|
|
otherwise flood the log with identical warnings."""
|
|
import logging
|
|
|
|
from anthias_server.lib import auth as auth_module
|
|
|
|
# Clear any state left by other tests sharing the in-process
|
|
# throttle dict.
|
|
auth_module._basic_auth_log_seen.clear()
|
|
|
|
creds = b64encode(f'alice:{_PWD_TOKEN_USER}'.encode()).decode('ascii')
|
|
client = Client()
|
|
with _enable_auth(), caplog.at_level(logging.WARNING):
|
|
for _ in range(5):
|
|
response = client.get(
|
|
'/api/v2/assets',
|
|
HTTP_AUTHORIZATION=f'Basic {creds}',
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
deprecated = [
|
|
r
|
|
for r in caplog.records
|
|
if 'DEPRECATED: HTTP Basic auth' in r.getMessage()
|
|
]
|
|
# Exactly one warning across all five identical requests.
|
|
assert len(deprecated) == 1, (
|
|
f'expected 1 DEPRECATED log line, got {len(deprecated)}'
|
|
)
|
|
|
|
# Different client_ip should not be throttled by the previous
|
|
# entry — a new tuple gets its own log line. Use TEST-NET-1
|
|
# (RFC 5737) so the value is unambiguously a documentation/test
|
|
# placeholder and Sonar's hardcoded-IP hotspot doesn't fire.
|
|
with _enable_auth(), caplog.at_level(logging.WARNING):
|
|
client.get(
|
|
'/api/v2/assets',
|
|
HTTP_AUTHORIZATION=f'Basic {creds}',
|
|
REMOTE_ADDR='192.0.2.42', # NOSONAR (RFC 5737 doc IP)
|
|
)
|
|
deprecated_after = [
|
|
r
|
|
for r in caplog.records
|
|
if 'DEPRECATED: HTTP Basic auth' in r.getMessage()
|
|
]
|
|
assert len(deprecated_after) == 2
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_auth_disabled_ignores_drf_authenticators(
|
|
authed_operator: User,
|
|
) -> None:
|
|
"""When ``settings['auth_backend'] == ''`` (auth disabled), the
|
|
documented contract is "API is fully open". DRF's stock auth
|
|
classes would violate that — ``SessionAuthentication`` raises
|
|
403 on unsafe methods without ``X-CSRFToken``,
|
|
``BasicAuthentication`` raises 401 on a malformed header. The
|
|
Anthias-flavoured wrappers (``GatedSessionAuthentication``,
|
|
``DeprecatedBasicAuthentication``) both inherit
|
|
``_AuthBackendGated`` which returns ``None`` early when auth is
|
|
disabled, so neither rejection fires.
|
|
|
|
This test asserts both shapes pass through to a 200, which is
|
|
impossible with stock DRF classes — the previous wiring would
|
|
have returned 401 on the wrong-Basic-creds case.
|
|
"""
|
|
client = Client()
|
|
# auth_backend is '' by default in tests; do NOT enter
|
|
# ``_enable_auth()`` here, that's the whole point.
|
|
|
|
# 1. Wrong Basic-auth header. Stock BasicAuthentication would 401.
|
|
creds = b64encode(f'alice:{_PWD_WRONG}'.encode()).decode('ascii')
|
|
response = client.get(
|
|
'/api/v2/assets', HTTP_AUTHORIZATION=f'Basic {creds}'
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# 2. Authenticated session + POST without CSRF token. Stock
|
|
# SessionAuthentication.enforce_csrf would 403.
|
|
client.force_login(authed_operator)
|
|
response = client.post(
|
|
'/api/v2/assets',
|
|
data='{}',
|
|
content_type='application/json',
|
|
)
|
|
# Either a normal 4xx for body-shape (no name, etc.) or a 200 —
|
|
# but explicitly NOT 403 (the CSRF rejection we're guarding
|
|
# against). The view dispatches and the auth/CSRF gate is silent.
|
|
assert response.status_code != 403
|