feat: surface failed token sign-in alerts (Closes #543)
CI / lint (pull_request) Successful in 1m10s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-11 05:35:35 +00:00
1 parent 98aecd8255
commit 350bfc067d
8 files changed
+300 -2

No files matched your search

+41
View File
@@ -825,6 +825,44 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
assert blocked.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_security_activity_surfaces_failed_and_blocked_token_sign_ins(access_control):
attacker_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
operator_transport = httpx.ASGITransport(app=main.app, client=("198.51.100.4", 1234))
async with httpx.AsyncClient(
transport=attacker_transport, base_url="https://test"
) as attacker:
failures = [
await attacker.post(
"/api/v1/session",
json={"access_token": "wrong", "device_label": "Unknown"},
)
for _ in range(4)
]
async with httpx.AsyncClient(
transport=operator_transport, base_url="https://test"
) as operator:
signed_in = await operator.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Phone",
},
)
activity = await operator.get("/api/v1/security-events")
assert [response.status_code for response in failures] == [401, 401, 401, 429]
assert signed_in.status_code == 200
assert activity.status_code == 200
assert activity.headers["cache-control"] == "no-store"
alerts = activity.json()["authentication_alerts"]
assert len(alerts) == 1
assert alerts[0]["method"] == "token"
assert alerts[0]["failed_count"] == 3
assert alerts[0]["blocked_count"] == 1
assert alerts[0]["first_at"] <= alerts[0]["last_at"]
@pytest.mark.anyio
async def test_successful_sign_in_clears_prior_failures(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
@@ -1555,6 +1593,9 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
time.sleep(0.15)
return 17
def record_blocked(self, *, method):
assert method == "token"
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+28
View File
@@ -736,6 +736,34 @@ process.stdout.write(JSON.stringify(state));
]
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
result = run_session_scenario(
"""
state.formatted = boundary.formatAuthenticationAlert({
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["formatted"] == {
"title": "12 failed token sign-ins · 3 blocked",
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
}
def test_security_activity_renders_authentication_alerts_before_journal_events():
source = SESSION_JS.read_text()
alerts = source.index("page.authentication_alerts.forEach")
events = source.index("page.events.forEach")
assert alerts < events
assert "boundary.formatAuthenticationAlert(alert)" in source
assert "row.className = 'security-event authentication-alert'" in source
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".authentication-alert { border-color:#d69e2e;" in css
def test_security_activity_explains_pending_outcome_confirmation():
source = SESSION_JS.read_text()
+94
View File
@@ -74,6 +74,100 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=300,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=300,
)
first.record_failure("203.0.113.7", method="token")
now[0] = 1_010.0
second.record_failure("198.51.100.4", method="token")
first.clear("203.0.113.7")
assert second.list_alerts() == [
{
"method": "token",
"failed_count": 2,
"blocked_count": 0,
"first_at": 1_000,
"last_at": 1_010,
}
]
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: 1_000.0,
max_failures=3,
window_seconds=60,
)
store.record_blocked(method="token")
assert store.list_alerts() == [
{
"method": "token",
"failed_count": 0,
"blocked_count": 1,
"first_at": 1_000,
"last_at": 1_000,
}
]
with sqlite3.connect(store.path) as connection:
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
assert "source_hash" not in columns
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_retention_seconds=300,
)
store.record_failure("203.0.113.7")
now[0] = 1_301.0
assert store.list_alerts() == []
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
alert_bucket_seconds=60,
max_alert_buckets=2,
)
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
now[0] = timestamp
store.record_failure(f"203.0.113.{index}")
with sqlite3.connect(store.path) as connection:
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
assert count == 2
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
+1
View File
@@ -53,6 +53,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
"status": "completed",
}
],
"authentication_alerts": [],
"next_cursor": None,
}
persisted = (security_access / "security.sqlite3").read_bytes()