feat: add audit, throttle and session foundations
Audit entries written only inside the caller's transaction, database throttle counters that refuse to run inside a transaction that could roll back, and a database session store with the user key in its own column, end_sessions and idle and absolute limits. Ticket 1.3. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
003ce6e0c2
commit
73505ac717
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "portal.accounts"
|
||||
label = "accounts"
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Absolute session limit; the idle limit is SESSION_COOKIE_AGE with save-every-request."""
|
||||
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .session_store import CREATED_AT
|
||||
|
||||
|
||||
class AbsoluteSessionLimitMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
session = request.session
|
||||
created = session.get(CREATED_AT)
|
||||
if created is not None and time.time() - created > settings.SESSION_ABSOLUTE_SECONDS:
|
||||
# flush() deletes the row; the empty session makes SessionMiddleware clear the cookie.
|
||||
session.flush()
|
||||
return self.get_response(request)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-15 13:40
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserSession',
|
||||
fields=[
|
||||
('session_key', models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name='session key')),
|
||||
('session_data', models.TextField(verbose_name='session data')),
|
||||
('expire_date', models.DateTimeField(db_index=True, verbose_name='expire date')),
|
||||
('username', models.CharField(blank=True, db_index=True, default='', max_length=256)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'session',
|
||||
'verbose_name_plural': 'sessions',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ThrottleCounter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('endpoint', models.CharField(max_length=64)),
|
||||
('subject', models.CharField(max_length=256)),
|
||||
('count', models.PositiveIntegerField(default=0)),
|
||||
('window_started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('blocked_until', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('endpoint', 'subject'), name='uniq_throttle_subject')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.contrib.sessions.base_session import AbstractBaseSession
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class UserSession(AbstractBaseSession):
|
||||
"""Database session with the user key in its own column (AD-6)."""
|
||||
|
||||
username = models.CharField(max_length=256, blank=True, default="", db_index=True)
|
||||
created_at = models.DateTimeField(default=timezone.now) # UTC
|
||||
|
||||
@classmethod
|
||||
def get_session_store_class(cls):
|
||||
from .session_store import SessionStore
|
||||
|
||||
return SessionStore
|
||||
|
||||
|
||||
class ThrottleCounter(models.Model):
|
||||
"""One row per (endpoint, subject); subject is `user:<key>` or `ip:<address>` (AD-7)."""
|
||||
|
||||
endpoint = models.CharField(max_length=64)
|
||||
subject = models.CharField(max_length=256)
|
||||
count = models.PositiveIntegerField(default=0)
|
||||
window_started = models.DateTimeField(default=timezone.now)
|
||||
blocked_until = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["endpoint", "subject"], name="uniq_throttle_subject")
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Database session store that fills the username and created_at columns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import UserSession
|
||||
|
||||
USER_KEY = "session_user_key"
|
||||
USER_SAM = "session_user_sam"
|
||||
CREATED_AT = "session_created_at" # epoch seconds, UTC
|
||||
|
||||
|
||||
class SessionStore(DBSessionStore):
|
||||
@classmethod
|
||||
def get_model_class(cls):
|
||||
return UserSession
|
||||
|
||||
def create_model_instance(self, data):
|
||||
obj = super().create_model_instance(data)
|
||||
obj.username = str(data.get(USER_KEY, "")).lower()
|
||||
created = data.get(CREATED_AT)
|
||||
obj.created_at = (
|
||||
datetime.fromtimestamp(created, tz=UTC) if created is not None else timezone.now()
|
||||
)
|
||||
return obj
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Session user and the only way to end sessions (AD-3, AD-6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import UserSession
|
||||
from .session_store import CREATED_AT, USER_KEY, USER_SAM
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionUser:
|
||||
key: str # lower-case, used for every database key
|
||||
sam: str # exact sAMAccountName from AD
|
||||
|
||||
@classmethod
|
||||
def from_sam(cls, sam: str) -> SessionUser:
|
||||
return cls(key=sam.lower(), sam=sam)
|
||||
|
||||
|
||||
def start_session(request, sam: str) -> SessionUser:
|
||||
"""Sign a user in on this request; the session key is rotated and old data dropped."""
|
||||
user = SessionUser.from_sam(sam)
|
||||
request.session.flush()
|
||||
request.session[USER_KEY] = user.key
|
||||
request.session[USER_SAM] = user.sam
|
||||
request.session[CREATED_AT] = time.time()
|
||||
return user
|
||||
|
||||
|
||||
def current_user(request) -> SessionUser | None:
|
||||
session = getattr(request, "session", None)
|
||||
if session is None:
|
||||
return None
|
||||
key = session.get(USER_KEY)
|
||||
sam = session.get(USER_SAM)
|
||||
if not key or not sam:
|
||||
return None
|
||||
return SessionUser(key=key, sam=sam)
|
||||
|
||||
|
||||
def end_sessions(user: SessionUser | str, except_session_key: str | None = None) -> int:
|
||||
"""Delete every session of one user, optionally keeping one. Returns how many were ended."""
|
||||
key = (user.key if isinstance(user, SessionUser) else str(user)).lower()
|
||||
if not key:
|
||||
return 0
|
||||
qs = UserSession.objects.filter(username=key)
|
||||
if except_session_key:
|
||||
qs = qs.exclude(session_key=except_session_key)
|
||||
deleted, _ = qs.delete()
|
||||
return deleted
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Database throttle counters, checked before any outbound call (AD-7)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ThrottleCounter
|
||||
|
||||
# Defaults, ready to move into the settings registry (AD-13).
|
||||
THROTTLE_MAX_FAILURES = 5
|
||||
THROTTLE_PAUSE_SECONDS = 15 * 60
|
||||
|
||||
Clock = Callable[[], datetime]
|
||||
|
||||
|
||||
class Throttled(Exception):
|
||||
def __init__(self, retry_after: int) -> None:
|
||||
self.retry_after = retry_after
|
||||
super().__init__(f"Too many attempts; retry in {retry_after} seconds")
|
||||
|
||||
|
||||
def _subjects(user: str | None, ip: str | None) -> list[str]:
|
||||
if not user and not ip:
|
||||
raise ValueError("throttle needs a user or an ip; refusing to do nothing")
|
||||
subjects = []
|
||||
if user:
|
||||
subjects.append(f"user:{user.lower()}")
|
||||
if ip:
|
||||
subjects.append(f"ip:{ip}")
|
||||
return subjects
|
||||
|
||||
|
||||
def check(
|
||||
endpoint: str,
|
||||
*,
|
||||
user: str | None = None,
|
||||
ip: str | None = None,
|
||||
clock: Clock = timezone.now,
|
||||
) -> None:
|
||||
"""Raise Throttled if the user or the IP is paused at this endpoint. Never writes."""
|
||||
now = clock()
|
||||
blocked = ThrottleCounter.objects.filter(
|
||||
endpoint=endpoint,
|
||||
subject__in=_subjects(user, ip),
|
||||
blocked_until__gt=now,
|
||||
).values_list("blocked_until", flat=True)
|
||||
latest = max(blocked, default=None)
|
||||
if latest is not None:
|
||||
raise Throttled(max(1, math.ceil((latest - now).total_seconds())))
|
||||
|
||||
|
||||
def hit(
|
||||
endpoint: str,
|
||||
*,
|
||||
user: str | None = None,
|
||||
ip: str | None = None,
|
||||
clock: Clock = timezone.now,
|
||||
max_failures: int = THROTTLE_MAX_FAILURES,
|
||||
pause_seconds: int = THROTTLE_PAUSE_SECONDS,
|
||||
) -> None:
|
||||
"""Count one failure for the user and the IP; pause a subject that reaches the limit."""
|
||||
subjects = _subjects(user, ip)
|
||||
if connection.in_atomic_block:
|
||||
raise RuntimeError(
|
||||
"throttle.hit() must be called after the failed work was rolled back, "
|
||||
"outside any open transaction"
|
||||
)
|
||||
now = clock()
|
||||
window = timedelta(seconds=pause_seconds)
|
||||
with transaction.atomic():
|
||||
for subject in subjects:
|
||||
counter, _ = ThrottleCounter.objects.get_or_create(
|
||||
endpoint=endpoint, subject=subject, defaults={"window_started": now}
|
||||
)
|
||||
qs = ThrottleCounter.objects.filter(pk=counter.pk)
|
||||
# Start a fresh window when the old one ran out or the pause is over.
|
||||
qs.filter(Q(window_started__lte=now - window) | Q(blocked_until__lte=now)).update(
|
||||
count=0, window_started=now, blocked_until=None
|
||||
)
|
||||
qs.update(count=F("count") + 1)
|
||||
qs.filter(count__gte=max_failures, blocked_until__isnull=True).update(
|
||||
blocked_until=now + window
|
||||
)
|
||||
|
||||
|
||||
def reset(endpoint: str, *, user: str) -> None:
|
||||
"""Clear the user's counter (call after a success).
|
||||
|
||||
IP counters expire on their own and are never reset: a valid sign-in must not let an
|
||||
attacker with one account clear the IP counter.
|
||||
"""
|
||||
ThrottleCounter.objects.filter(endpoint=endpoint, subject__in=_subjects(user, None)).delete()
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuditConfig(AppConfig):
|
||||
name = "portal.audit"
|
||||
label = "audit"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.18 on 2026-10-15 13:40
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AuditEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actor', models.CharField(db_index=True, max_length=256)),
|
||||
('action', models.CharField(db_index=True, max_length=128)),
|
||||
('target', models.CharField(blank=True, default='', max_length=256)),
|
||||
('ip', models.CharField(blank=True, default='', max_length=64)),
|
||||
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
|
||||
('detail', models.JSONField(blank=True, default=dict)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at', '-id'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class AuditEntry(models.Model):
|
||||
"""Who did what to whom, when and from where. Written only by `audit.services` (AD-4, AD-19)."""
|
||||
|
||||
actor = models.CharField(max_length=256, db_index=True) # lower-case key
|
||||
action = models.CharField(max_length=128, db_index=True)
|
||||
target = models.CharField(max_length=256, blank=True, default="")
|
||||
ip = models.CharField(max_length=64, blank=True, default="")
|
||||
created_at = models.DateTimeField(default=timezone.now, db_index=True) # UTC
|
||||
detail = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at", "-id"]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Audit service: the only writer of audit entries (AD-4, AD-19)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import connection, transaction
|
||||
|
||||
from .models import AuditEntry
|
||||
|
||||
_FORBIDDEN_FRAGMENTS = ("password", "passwd", "pwd", "token", "secret")
|
||||
|
||||
|
||||
class AuditOutsideTransaction(RuntimeError):
|
||||
"""`record` was called without an open transaction, so it cannot share the change's fate."""
|
||||
|
||||
|
||||
class AuditInsideTransaction(RuntimeError):
|
||||
"""`record_failure` was called in an open transaction, where a rollback would erase it."""
|
||||
|
||||
|
||||
def _check_detail(value: Any, path: str = "detail") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, inner in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(fragment in lowered for fragment in _FORBIDDEN_FRAGMENTS):
|
||||
raise ValueError(f"{path}: key {key!r} may hold a secret and is not allowed")
|
||||
_check_detail(inner, f"{path}.{key}")
|
||||
elif isinstance(value, list | tuple):
|
||||
for item in value:
|
||||
_check_detail(item, path)
|
||||
|
||||
|
||||
def _build(actor: str, action: str, target: str, ip: str, detail: dict | None) -> AuditEntry:
|
||||
detail = detail or {}
|
||||
_check_detail(detail)
|
||||
return AuditEntry(
|
||||
actor=(actor or "").lower(),
|
||||
action=action,
|
||||
target=target or "",
|
||||
ip=ip or "",
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def record(
|
||||
actor: str,
|
||||
action: str,
|
||||
*,
|
||||
target: str = "",
|
||||
ip: str = "",
|
||||
detail: dict | None = None,
|
||||
) -> AuditEntry:
|
||||
"""Write an entry inside the caller's open transaction; it rolls back with the change."""
|
||||
entry = _build(actor, action, target, ip, detail)
|
||||
if not connection.in_atomic_block:
|
||||
raise AuditOutsideTransaction("audit.record() must run inside transaction.atomic()")
|
||||
entry.save()
|
||||
return entry
|
||||
|
||||
|
||||
def record_failure(
|
||||
actor: str,
|
||||
action: str,
|
||||
*,
|
||||
target: str = "",
|
||||
ip: str = "",
|
||||
detail: dict | None = None,
|
||||
) -> AuditEntry:
|
||||
"""Write a failure entry in its own transaction, after the failed change was rolled back."""
|
||||
entry = _build(actor, action, target, ip, detail)
|
||||
if connection.in_atomic_block:
|
||||
raise AuditInsideTransaction(
|
||||
"audit.record_failure() must run after the failed change was rolled back, "
|
||||
"outside any open transaction"
|
||||
)
|
||||
with transaction.atomic():
|
||||
entry.save()
|
||||
return entry
|
||||
@@ -29,6 +29,8 @@ INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.staticfiles",
|
||||
"portal.audit",
|
||||
"portal.accounts",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -37,6 +39,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"portal.accounts.middleware.AbsoluteSessionLimitMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -91,7 +94,10 @@ STORAGES = {
|
||||
}
|
||||
|
||||
# Sessions in the database; cookies secure, HttpOnly, SameSite=Lax (AD-6).
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||
SESSION_ENGINE = "portal.accounts.session_store"
|
||||
SESSION_COOKIE_AGE = 60 * 60 # idle limit: 1 hour, renewed on every request
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
SESSION_ABSOLUTE_SECONDS = 4 * 60 * 60 # absolute limit: 4 hours
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from django.db import transaction
|
||||
|
||||
from portal.audit import services
|
||||
from portal.audit.models import AuditEntry
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_record_rolls_back_with_the_change():
|
||||
with pytest.raises(RuntimeError), transaction.atomic():
|
||||
services.record("Alice", "profile.update", target="alice", ip="10.0.0.1")
|
||||
raise RuntimeError("change failed")
|
||||
assert AuditEntry.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_record_persists_on_commit_and_lowercases_actor():
|
||||
with transaction.atomic():
|
||||
services.record("Alice", "profile.update", target="alice", ip="10.0.0.1", detail={"a": 1})
|
||||
entry = AuditEntry.objects.get()
|
||||
assert (entry.actor, entry.action, entry.ip, entry.detail) == (
|
||||
"alice",
|
||||
"profile.update",
|
||||
"10.0.0.1",
|
||||
{"a": 1},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_record_outside_transaction_raises_and_writes_nothing():
|
||||
with pytest.raises(services.AuditOutsideTransaction):
|
||||
services.record("alice", "x")
|
||||
assert AuditEntry.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
@pytest.mark.parametrize("key", ["password", "New_Token", "client_secret", "pwd", "oldPassword"])
|
||||
def test_secret_keys_are_refused(key):
|
||||
with pytest.raises(ValueError), transaction.atomic():
|
||||
services.record("alice", "x", detail={"ok": 1, "nested": {key: "v"}})
|
||||
with pytest.raises(ValueError):
|
||||
services.record_failure("alice", "x", detail={key: "v"})
|
||||
assert AuditEntry.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_record_failure_inside_transaction_raises_and_writes_nothing():
|
||||
with pytest.raises(services.AuditInsideTransaction), transaction.atomic():
|
||||
services.record_failure("alice", "x.failed")
|
||||
assert AuditEntry.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_record_failure_persists_after_rollback():
|
||||
with pytest.raises(RuntimeError), transaction.atomic():
|
||||
services.record("alice", "x")
|
||||
raise RuntimeError("boom")
|
||||
services.record_failure("alice", "x.failed", detail={"reason": "ad"})
|
||||
assert list(AuditEntry.objects.values_list("action", flat=True)) == ["x.failed"]
|
||||
@@ -0,0 +1,123 @@
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import Client, RequestFactory
|
||||
from django.utils import timezone
|
||||
|
||||
from portal.accounts.middleware import AbsoluteSessionLimitMiddleware
|
||||
from portal.accounts.models import UserSession
|
||||
from portal.accounts.session_store import CREATED_AT, SessionStore
|
||||
from portal.accounts.sessions import SessionUser, current_user, end_sessions, start_session
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def make_session(sam):
|
||||
request = RequestFactory().get("/")
|
||||
request.session = SessionStore()
|
||||
start_session(request, sam)
|
||||
request.session.save()
|
||||
return request
|
||||
|
||||
|
||||
def test_username_column_is_lowercase_key_and_sam_keeps_case():
|
||||
request = make_session("Alice")
|
||||
row = UserSession.objects.get(session_key=request.session.session_key)
|
||||
assert row.username == "alice"
|
||||
assert current_user(request) == SessionUser(key="alice", sam="Alice")
|
||||
|
||||
|
||||
def test_start_session_rotates_key_and_drops_old_data():
|
||||
request = RequestFactory().get("/")
|
||||
request.session = SessionStore()
|
||||
request.session["stale"] = 1
|
||||
request.session.save()
|
||||
old_key = request.session.session_key
|
||||
start_session(request, "alice")
|
||||
request.session.save()
|
||||
assert request.session.session_key != old_key
|
||||
assert "stale" not in request.session
|
||||
assert not UserSession.objects.filter(session_key=old_key).exists()
|
||||
|
||||
|
||||
def test_end_sessions_only_target_user():
|
||||
for _ in range(3):
|
||||
make_session("a")
|
||||
for _ in range(2):
|
||||
make_session("b")
|
||||
assert end_sessions("a") == 3
|
||||
assert UserSession.objects.filter(username="a").count() == 0
|
||||
assert UserSession.objects.filter(username="b").count() == 2
|
||||
|
||||
|
||||
def test_end_sessions_except_session_key_and_unknown_user():
|
||||
keep = make_session("a").session.session_key
|
||||
make_session("a")
|
||||
assert end_sessions(SessionUser("a", "A"), except_session_key=keep) == 1
|
||||
assert list(UserSession.objects.values_list("session_key", flat=True)) == [keep]
|
||||
assert end_sessions("nobody") == 0
|
||||
|
||||
|
||||
def test_idle_limit_expired_session_is_gone():
|
||||
request = make_session("a")
|
||||
UserSession.objects.update(expire_date=timezone.now() - timedelta(seconds=1))
|
||||
assert SessionStore(request.session.session_key).get(CREATED_AT) is None
|
||||
|
||||
|
||||
def test_settings_defaults():
|
||||
assert settings.SESSION_COOKIE_AGE == 3600
|
||||
assert settings.SESSION_ABSOLUTE_SECONDS == 14400
|
||||
assert settings.SESSION_SAVE_EVERY_REQUEST is True
|
||||
|
||||
|
||||
def _client_with_session(created_at):
|
||||
store = SessionStore()
|
||||
request = RequestFactory().get("/")
|
||||
request.session = store
|
||||
start_session(request, "alice")
|
||||
store[CREATED_AT] = created_at
|
||||
store.save()
|
||||
client = Client()
|
||||
client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key
|
||||
return client, store.session_key
|
||||
|
||||
|
||||
def test_absolute_limit_removes_session_and_clears_cookie():
|
||||
assert any(m.endswith("AbsoluteSessionLimitMiddleware") for m in settings.MIDDLEWARE)
|
||||
client, key = _client_with_session(time.time() - settings.SESSION_ABSOLUTE_SECONDS - 5)
|
||||
response = client.get("/healthz")
|
||||
assert not UserSession.objects.filter(session_key=key).exists()
|
||||
assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
|
||||
|
||||
|
||||
def test_session_within_limits_survives_a_request():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
client.get("/healthz")
|
||||
assert UserSession.objects.filter(session_key=key).exists()
|
||||
|
||||
|
||||
def test_middleware_callable_directly():
|
||||
request = make_session("alice")
|
||||
AbsoluteSessionLimitMiddleware(lambda r: None)(request)
|
||||
assert current_user(request) is not None
|
||||
|
||||
|
||||
def test_request_renews_expire_date_to_one_hour_ahead():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
UserSession.objects.filter(session_key=key).update(
|
||||
expire_date=timezone.now() + timedelta(minutes=5)
|
||||
)
|
||||
client.get("/healthz")
|
||||
remaining = UserSession.objects.get(session_key=key).expire_date - timezone.now()
|
||||
assert timedelta(minutes=59) < remaining <= timedelta(hours=1)
|
||||
|
||||
|
||||
def test_request_with_idle_expired_session_has_no_session_and_clears_cookie():
|
||||
client, key = _client_with_session(time.time() - 60)
|
||||
UserSession.objects.filter(session_key=key).update(
|
||||
expire_date=timezone.now() - timedelta(seconds=1)
|
||||
)
|
||||
response = client.get("/healthz")
|
||||
assert response.cookies[settings.SESSION_COOKIE_NAME].value == ""
|
||||
@@ -0,0 +1,91 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from django.db import transaction
|
||||
|
||||
from portal.accounts import throttle
|
||||
from portal.accounts.models import ThrottleCounter
|
||||
|
||||
T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||
EP = "sign-in"
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
|
||||
def fail(n, at=T0, **kw):
|
||||
for _ in range(n):
|
||||
throttle.hit(EP, clock=lambda: at, **kw)
|
||||
|
||||
|
||||
def test_below_limit_passes():
|
||||
fail(4, user="alice")
|
||||
throttle.check(EP, user="alice", clock=lambda: T0)
|
||||
|
||||
|
||||
def test_user_is_throttled_but_others_pass():
|
||||
fail(5, user="Alice", ip="1.1.1.1")
|
||||
with pytest.raises(throttle.Throttled) as exc:
|
||||
throttle.check(EP, user="alice", clock=lambda: T0)
|
||||
assert exc.value.retry_after == 900
|
||||
throttle.check(EP, user="bob", ip="2.2.2.2", clock=lambda: T0)
|
||||
|
||||
|
||||
def test_ip_is_throttled_across_users():
|
||||
fail(5, user="a", ip="1.1.1.1")
|
||||
with pytest.raises(throttle.Throttled):
|
||||
throttle.check(EP, user="other", ip="1.1.1.1", clock=lambda: T0)
|
||||
throttle.check(EP, user="other", ip="3.3.3.3", clock=lambda: T0)
|
||||
|
||||
|
||||
def test_other_endpoint_is_unaffected():
|
||||
fail(5, user="alice")
|
||||
throttle.check("password-change", user="alice", clock=lambda: T0)
|
||||
|
||||
|
||||
def test_pause_elapses():
|
||||
fail(5, user="alice")
|
||||
later = T0 + timedelta(seconds=901)
|
||||
throttle.check(EP, user="alice", clock=lambda: later)
|
||||
# a new failure after the pause starts a fresh count
|
||||
throttle.hit(EP, user="alice", clock=lambda: later)
|
||||
assert ThrottleCounter.objects.get(subject="user:alice").count == 1
|
||||
|
||||
|
||||
def test_stale_window_restarts_count():
|
||||
fail(4, user="alice")
|
||||
later = T0 + timedelta(minutes=16)
|
||||
throttle.hit(EP, user="alice", clock=lambda: later)
|
||||
assert ThrottleCounter.objects.get().count == 1
|
||||
|
||||
|
||||
def test_reset_clears_user_but_never_the_ip():
|
||||
fail(5, user="alice", ip="1.1.1.1")
|
||||
throttle.reset(EP, user="alice")
|
||||
throttle.check(EP, user="alice", clock=lambda: T0)
|
||||
with pytest.raises(throttle.Throttled):
|
||||
throttle.check(EP, ip="1.1.1.1", clock=lambda: T0)
|
||||
|
||||
|
||||
def test_reset_has_no_ip_parameter():
|
||||
with pytest.raises(TypeError):
|
||||
throttle.reset(EP, user="alice", ip="1.1.1.1")
|
||||
|
||||
|
||||
def test_hit_inside_transaction_raises_and_counts_nothing():
|
||||
with pytest.raises(RuntimeError), transaction.atomic():
|
||||
throttle.hit(EP, user="alice", clock=lambda: T0)
|
||||
assert ThrottleCounter.objects.count() == 0
|
||||
|
||||
|
||||
def test_no_subject_raises_for_all_functions():
|
||||
with pytest.raises(ValueError):
|
||||
throttle.check(EP)
|
||||
with pytest.raises(ValueError):
|
||||
throttle.hit(EP)
|
||||
with pytest.raises(ValueError):
|
||||
throttle.reset(EP, user="")
|
||||
|
||||
|
||||
def test_check_never_writes():
|
||||
throttle.check(EP, user="alice", ip="1.1.1.1", clock=lambda: T0)
|
||||
assert ThrottleCounter.objects.count() == 0
|
||||
Reference in New Issue
Block a user