Coverage for src / lexigram / admin / auth / store / login_attempt_sql.py: 32%
60 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""SQL-backed implementation of AdminLoginAttemptStoreProtocol.
3Owns all DDL and DML for the ``admin_login_attempts`` table. The service
4layer depends only on ``AdminLoginAttemptStoreProtocol`` from
5``lexigram.admin.auth.protocols`` — never on this class directly.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING, Any
11import uuid
13from lexigram.contracts.data import DatabaseProviderProtocol
14from lexigram.di.decorators import inject
15from lexigram.logging import get_logger
17if TYPE_CHECKING:
18 from lexigram.admin.auth.types import AdminLoginAttempt
20logger = get_logger(__name__)
22_TABLE = "admin_login_attempts"
25@inject
26class AdminLoginAttemptSqlStore:
27 """SQL-backed store for admin login attempt records.
29 Implements ``AdminLoginAttemptStoreProtocol`` via structural subtyping.
30 Manages the ``admin_login_attempts`` table including DDL bootstrap,
31 failure counting for rate limiting, and attempt persistence.
32 """
34 def __init__(self, db: DatabaseProviderProtocol) -> None:
35 """Initialise with a resolved database provider.
37 Args:
38 db: Framework database provider exposing ``execute`` and
39 ``execute_query``.
40 """
41 self._db = db
42 self._initialized = False
44 # ------------------------------------------------------------------
45 # Schema bootstrap (DDL)
46 # ------------------------------------------------------------------
48 async def ensure_schema(self) -> None:
49 """Create table and indexes if they do not exist.
51 Safe to call multiple times — idempotent after the first successful
52 run. Uses ``CREATE TABLE IF NOT EXISTS`` and
53 ``CREATE INDEX IF NOT EXISTS`` so concurrent callers are safe.
55 Raises:
56 Exception: Propagates any unexpected DDL failure so the caller
57 surfaces the problem rather than silently skipping persistence.
58 """
59 if self._initialized:
60 return
62 try:
63 await self._db.execute(
64 """
65 CREATE TABLE IF NOT EXISTS admin_login_attempts (
66 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
67 email VARCHAR(255) NOT NULL,
68 ip_address VARCHAR(45) NOT NULL,
69 user_agent TEXT NOT NULL DEFAULT '',
70 success BOOLEAN NOT NULL DEFAULT FALSE,
71 failure_reason VARCHAR(50),
72 attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
73 )
74 """,
75 [],
76 )
77 await self._db.execute(
78 """
79 CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_email_attempted_at
80 ON admin_login_attempts(email, attempted_at DESC)
81 """,
82 [],
83 )
84 await self._db.execute(
85 """
86 CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_ip_attempted_at
87 ON admin_login_attempts(ip_address, attempted_at DESC)
88 """,
89 [],
90 )
91 self._initialized = True
92 logger.info("✅ %s schema ready", _TABLE)
93 except Exception as _schema_err: # noqa: BLE001 — DDL may raise DB-specific errors; log and propagate
94 logger.exception("Failed to initialise %s schema", _TABLE)
95 raise
97 # ------------------------------------------------------------------
98 # DML
99 # ------------------------------------------------------------------
101 async def insert(self, attempt: AdminLoginAttempt) -> None:
102 """Insert a login attempt record.
104 Generates a fresh UUID when ``attempt.id`` is empty so callers may
105 pass an uninitialised id without failing the NOT NULL constraint.
107 Args:
108 attempt: Login attempt data to persist.
109 """
110 await self.ensure_schema()
111 attempt_id = attempt.id or str(uuid.uuid4())
112 sql = (
113 "INSERT INTO admin_login_attempts "
114 "(id, email, ip_address, user_agent, success, failure_reason, attempted_at) "
115 "VALUES (?, ?, ?, ?, ?, ?, ?)"
116 )
117 await self._db.execute(
118 sql,
119 (
120 attempt_id,
121 attempt.email,
122 attempt.ip_address,
123 attempt.user_agent,
124 attempt.success,
125 attempt.failure_reason,
126 attempt.attempted_at,
127 ),
128 )
129 logger.debug(
130 "login_attempt.inserted",
131 email=attempt.email,
132 success=attempt.success,
133 failure_reason=attempt.failure_reason,
134 )
136 async def count_recent_failures(self, email: str, since_seconds: int) -> int:
137 """Count failed login attempts for email within the last N seconds.
139 Args:
140 email: Email address to query.
141 since_seconds: Look-back window in seconds.
143 Returns:
144 Number of failed attempts within the window.
145 """
146 await self.ensure_schema()
147 sql = (
148 "SELECT COUNT(*) AS count FROM admin_login_attempts "
149 "WHERE email = ? AND success = FALSE "
150 f"AND attempted_at > NOW() - INTERVAL '{since_seconds} seconds'"
151 )
152 result = await self._db.execute_query(sql, [email])
153 count = self._extract_count(result)
154 logger.debug(
155 "login_attempt.count_recent_failures",
156 email=email,
157 since_seconds=since_seconds,
158 count=count,
159 )
160 return count
162 async def count_recent_failures_by_ip(
163 self, ip_address: str, since_seconds: int
164 ) -> int:
165 """Count failed login attempts from IP within the last N seconds.
167 Args:
168 ip_address: Client IP address to query.
169 since_seconds: Look-back window in seconds.
171 Returns:
172 Number of failed attempts within the window.
173 """
174 await self.ensure_schema()
175 sql = (
176 "SELECT COUNT(*) AS count FROM admin_login_attempts "
177 "WHERE ip_address = ? AND success = FALSE "
178 f"AND attempted_at > NOW() - INTERVAL '{since_seconds} seconds'"
179 )
180 result = await self._db.execute_query(sql, [ip_address])
181 count = self._extract_count(result)
182 logger.debug(
183 "login_attempt.count_recent_failures_by_ip",
184 ip_address=ip_address,
185 since_seconds=since_seconds,
186 count=count,
187 )
188 return count
190 async def clear_failures(self, email: str) -> None:
191 """Delete all failure records for email.
193 Called immediately after a successful login so subsequent failure
194 counts start from zero.
196 Args:
197 email: Email address whose failure records to remove.
198 """
199 await self.ensure_schema()
200 sql = "DELETE FROM admin_login_attempts WHERE email = ? AND success = FALSE"
201 await self._db.execute(sql, (email,))
202 logger.debug("login_attempt.failures_cleared", email=email)
204 # ------------------------------------------------------------------
205 # Internal helpers
206 # ------------------------------------------------------------------
208 @staticmethod
209 def _extract_count(result: Any) -> int:
210 """Normalise heterogeneous query result shapes into an integer count.
212 Args:
213 result: Raw result returned by ``execute_query``.
215 Returns:
216 The integer value of the ``count`` column, or ``0`` when absent.
217 """
218 if hasattr(result, "rows") and result.rows:
219 return int(result.rows[0].get("count", 0))
220 if isinstance(result, list) and result:
221 row = result[0]
222 if isinstance(row, dict):
223 return int(row.get("count", 0))
224 return 0
227__all__ = ["AdminLoginAttemptSqlStore"]