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

+89 -1
View File
@@ -49,6 +49,9 @@ class LoginAttemptStore:
window_seconds: int,
max_entries: int = 10_000,
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:
self.path = Path(path)
self.clock = clock
@@ -56,6 +59,9 @@ class LoginAttemptStore:
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
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
def _digest(source: str) -> str:
@@ -85,6 +91,19 @@ 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
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
@@ -154,7 +173,35 @@ class LoginAttemptStore:
"Sign-in throttling is temporarily unavailable"
) from exc
def record_failure(self, source: str) -> None:
def _record_alert(
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()
source_hash = self._digest(source)
try:
@@ -188,11 +235,52 @@ class LoginAttemptStore:
""",
(self.max_entries,),
)
self._record_alert(connection, method, blocked=False, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) 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:
try:
with self._connect() as connection:
+13 -1
View File
@@ -947,6 +947,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
headers={"Cache-Control": "no-store"},
)
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(
{"detail": "Too many sign-in attempts"},
status_code=429,
@@ -1034,7 +1042,10 @@ async def list_security_events(
page = await asyncio.to_thread(
_security_event_store().list, limit=limit, cursor=cursor
)
except SecurityEventStoreError:
authentication_alerts = await asyncio.to_thread(
_login_attempt_store().list_alerts, limit=24
)
except (SecurityEventStoreError, LoginAttemptStoreError):
raise HTTPException(
status_code=503, detail="Security activity is temporarily unavailable"
)
@@ -1052,6 +1063,7 @@ async def list_security_events(
}
for event in page.events
],
"authentication_alerts": authentication_alerts,
"next_cursor": page.next_cursor,
},
headers={"Cache-Control": "no-store"},