Compare commits
No commits in common. "23c8e57a8edee136470e784cd7e06b59bd9c55ea" and "98aecd82553e3fdbed8705357c7538b12a610413" have entirely different histories.
23c8e57a8e
...
98aecd8255
|
|
@ -30,8 +30,6 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
|
||||||
.authentication-alert strong { color:#f6c453; }
|
|
||||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||||
#load-more-security-activity { width:100%; min-height:44px; }
|
#load-more-security-activity { width:100%; min-height:44px; }
|
||||||
button:hover { filter: brightness(1.15); }
|
button:hover { filter: brightness(1.15); }
|
||||||
|
|
|
||||||
|
|
@ -142,20 +142,6 @@
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||||
if (!append) {
|
|
||||||
page.authentication_alerts.forEach(alert => {
|
|
||||||
const row = root.document.createElement('article');
|
|
||||||
row.className = 'security-event authentication-alert';
|
|
||||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
|
||||||
const title = root.document.createElement('strong');
|
|
||||||
title.textContent = formatted.title;
|
|
||||||
const details = root.document.createElement('span');
|
|
||||||
details.className = 'small muted';
|
|
||||||
details.textContent = formatted.detail;
|
|
||||||
row.append(title, details);
|
|
||||||
activityList.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const labels = {
|
const labels = {
|
||||||
sign_in: 'Signed in',
|
sign_in: 'Signed in',
|
||||||
sign_out: 'Signed out',
|
sign_out: 'Signed out',
|
||||||
|
|
@ -679,21 +665,6 @@
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAuthenticationAlert(alert) {
|
|
||||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
|
||||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
|
||||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
|
||||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
|
||||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
|
||||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
|
||||||
'en-US', { timeZone: 'UTC' },
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listSecurityEvents(cursor = null) {
|
async function listSecurityEvents(cursor = null) {
|
||||||
const query = new URLSearchParams({ limit: '25' });
|
const query = new URLSearchParams({ limit: '25' });
|
||||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||||
|
|
@ -702,8 +673,6 @@
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
return {
|
return {
|
||||||
events: Array.isArray(payload.events) ? payload.events : [],
|
events: Array.isArray(payload.events) ? payload.events : [],
|
||||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
|
||||||
? payload.authentication_alerts : [],
|
|
||||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -753,7 +722,6 @@
|
||||||
listActiveDevices,
|
listActiveDevices,
|
||||||
listPasskeys,
|
listPasskeys,
|
||||||
listSecurityEvents,
|
listSecurityEvents,
|
||||||
formatAuthenticationAlert,
|
|
||||||
enrollPasskey,
|
enrollPasskey,
|
||||||
revokeActiveDevice,
|
revokeActiveDevice,
|
||||||
revokePasskey,
|
revokePasskey,
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,6 @@ class LoginAttemptStore:
|
||||||
window_seconds: int,
|
window_seconds: int,
|
||||||
max_entries: int = 10_000,
|
max_entries: int = 10_000,
|
||||||
lock_timeout_seconds: float = 0.1,
|
lock_timeout_seconds: float = 0.1,
|
||||||
alert_bucket_seconds: int = 60 * 60,
|
|
||||||
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
|
||||||
max_alert_buckets: int = 720,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.path = Path(path)
|
self.path = Path(path)
|
||||||
self.clock = clock
|
self.clock = clock
|
||||||
|
|
@ -59,9 +56,6 @@ class LoginAttemptStore:
|
||||||
self.window_seconds = max(1, window_seconds)
|
self.window_seconds = max(1, window_seconds)
|
||||||
self.max_entries = max(1, max_entries)
|
self.max_entries = max(1, max_entries)
|
||||||
self.lock_timeout_seconds = lock_timeout_seconds
|
self.lock_timeout_seconds = lock_timeout_seconds
|
||||||
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
|
||||||
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
|
||||||
self.max_alert_buckets = max(1, max_alert_buckets)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _digest(source: str) -> str:
|
def _digest(source: str) -> str:
|
||||||
|
|
@ -91,19 +85,6 @@ class LoginAttemptStore:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS login_alerts (
|
|
||||||
method TEXT NOT NULL,
|
|
||||||
bucket_started_at INTEGER NOT NULL,
|
|
||||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
blocked_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
first_at INTEGER NOT NULL,
|
|
||||||
last_at INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (method, bucket_started_at)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return connection
|
return connection
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise LoginAttemptStoreError(
|
raise LoginAttemptStoreError(
|
||||||
|
|
@ -173,35 +154,7 @@ class LoginAttemptStore:
|
||||||
"Sign-in throttling is temporarily unavailable"
|
"Sign-in throttling is temporarily unavailable"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def _record_alert(
|
def record_failure(self, source: str) -> None:
|
||||||
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
|
||||||
) -> None:
|
|
||||||
safe_method = method if method in {"token", "passkey"} else "unknown"
|
|
||||||
occurred_at = int(now)
|
|
||||||
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
|
||||||
failed = 0 if blocked else 1
|
|
||||||
blocked_count = 1 if blocked else 0
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO login_alerts(
|
|
||||||
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
|
||||||
failed_count = failed_count + excluded.failed_count,
|
|
||||||
blocked_count = blocked_count + excluded.blocked_count,
|
|
||||||
first_at = MIN(first_at, excluded.first_at),
|
|
||||||
last_at = MAX(last_at, excluded.last_at)
|
|
||||||
""",
|
|
||||||
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
|
||||||
"(SELECT method, bucket_started_at FROM login_alerts "
|
|
||||||
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
|
||||||
(self.max_alert_buckets,),
|
|
||||||
)
|
|
||||||
|
|
||||||
def record_failure(self, source: str, *, method: str = "token") -> None:
|
|
||||||
now = self.clock()
|
now = self.clock()
|
||||||
source_hash = self._digest(source)
|
source_hash = self._digest(source)
|
||||||
try:
|
try:
|
||||||
|
|
@ -235,52 +188,11 @@ class LoginAttemptStore:
|
||||||
""",
|
""",
|
||||||
(self.max_entries,),
|
(self.max_entries,),
|
||||||
)
|
)
|
||||||
self._record_alert(connection, method, blocked=False, now=now)
|
|
||||||
except (OSError, sqlite3.Error) as exc:
|
except (OSError, sqlite3.Error) as exc:
|
||||||
raise LoginAttemptStoreError(
|
raise LoginAttemptStoreError(
|
||||||
"Sign-in throttling is temporarily unavailable"
|
"Sign-in throttling is temporarily unavailable"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
def record_blocked(self, *, method: str = "token") -> None:
|
|
||||||
now = self.clock()
|
|
||||||
try:
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._record_alert(connection, method, blocked=True, now=now)
|
|
||||||
except (OSError, sqlite3.Error) as exc:
|
|
||||||
raise LoginAttemptStoreError(
|
|
||||||
"Sign-in throttling is temporarily unavailable"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
|
||||||
bounded_limit = min(100, max(1, limit))
|
|
||||||
now = int(self.clock())
|
|
||||||
try:
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute(
|
|
||||||
"DELETE FROM login_alerts WHERE last_at < ?",
|
|
||||||
(now - self.alert_retention_seconds,),
|
|
||||||
)
|
|
||||||
rows = connection.execute(
|
|
||||||
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
|
||||||
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
|
||||||
(bounded_limit,),
|
|
||||||
).fetchall()
|
|
||||||
except (OSError, sqlite3.Error) as exc:
|
|
||||||
raise LoginAttemptStoreError(
|
|
||||||
"Sign-in throttling is temporarily unavailable"
|
|
||||||
) from exc
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"method": row[0],
|
|
||||||
"failed_count": row[1],
|
|
||||||
"blocked_count": row[2],
|
|
||||||
"first_at": row[3],
|
|
||||||
"last_at": row[4],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
def clear(self, source: str) -> None:
|
def clear(self, source: str) -> None:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
|
|
|
||||||
14
src/main.py
14
src/main.py
|
|
@ -947,14 +947,6 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
if retry_after:
|
if retry_after:
|
||||||
try:
|
|
||||||
await asyncio.to_thread(attempts.record_blocked, method="token")
|
|
||||||
except LoginAttemptStoreError:
|
|
||||||
return JSONResponse(
|
|
||||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
|
||||||
status_code=503,
|
|
||||||
headers={"Cache-Control": "no-store"},
|
|
||||||
)
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"detail": "Too many sign-in attempts"},
|
{"detail": "Too many sign-in attempts"},
|
||||||
status_code=429,
|
status_code=429,
|
||||||
|
|
@ -1042,10 +1034,7 @@ async def list_security_events(
|
||||||
page = await asyncio.to_thread(
|
page = await asyncio.to_thread(
|
||||||
_security_event_store().list, limit=limit, cursor=cursor
|
_security_event_store().list, limit=limit, cursor=cursor
|
||||||
)
|
)
|
||||||
authentication_alerts = await asyncio.to_thread(
|
except SecurityEventStoreError:
|
||||||
_login_attempt_store().list_alerts, limit=24
|
|
||||||
)
|
|
||||||
except (SecurityEventStoreError, LoginAttemptStoreError):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503, detail="Security activity is temporarily unavailable"
|
status_code=503, detail="Security activity is temporarily unavailable"
|
||||||
)
|
)
|
||||||
|
|
@ -1063,7 +1052,6 @@ async def list_security_events(
|
||||||
}
|
}
|
||||||
for event in page.events
|
for event in page.events
|
||||||
],
|
],
|
||||||
"authentication_alerts": authentication_alerts,
|
|
||||||
"next_cursor": page.next_cursor,
|
"next_cursor": page.next_cursor,
|
||||||
},
|
},
|
||||||
headers={"Cache-Control": "no-store"},
|
headers={"Cache-Control": "no-store"},
|
||||||
|
|
|
||||||
|
|
@ -825,44 +825,6 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
|
||||||
assert blocked.headers["cache-control"] == "no-store"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||||
|
|
@ -1593,9 +1555,6 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||||
time.sleep(0.15)
|
time.sleep(0.15)
|
||||||
return 17
|
return 17
|
||||||
|
|
||||||
def record_blocked(self, *, method):
|
|
||||||
assert method == "token"
|
|
||||||
|
|
||||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
|
|
||||||
|
|
@ -736,34 +736,6 @@ 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():
|
def test_security_activity_explains_pending_outcome_confirmation():
|
||||||
source = SESSION_JS.read_text()
|
source = SESSION_JS.read_text()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,100 +74,6 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
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):
|
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
|
||||||
now = [1_000.0]
|
now = [1_000.0]
|
||||||
database = tmp_path / "login-attempts.sqlite3"
|
database = tmp_path / "login-attempts.sqlite3"
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"authentication_alerts": [],
|
|
||||||
"next_cursor": None,
|
"next_cursor": None,
|
||||||
}
|
}
|
||||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user