Coverage for src / lexigram / admin / auth / services / login_attempt_service.py: 20%

92 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Admin login attempt service — IP rate limiting and account lockout.""" 

2 

3from __future__ import annotations 

4 

5from datetime import UTC, datetime 

6import hashlib 

7 

8from lexigram.admin.auth.errors import AccountLockedError, RateLimitExceededError 

9from lexigram.admin.auth.protocols import ( 

10 AdminAccountLockoutStoreProtocol, 

11 AdminAuditLogServiceProtocol, 

12 AdminLoginAttemptStoreProtocol, 

13) 

14from lexigram.admin.auth.types import ( 

15 AdminLockoutStatus, 

16 AdminLoginAttempt, 

17 AdminSecurityEventType, 

18) 

19from lexigram.contracts.infra.cache import CacheBackendProtocol 

20from lexigram.di.decorators import inject 

21from lexigram.logging import get_logger 

22 

23logger = get_logger(__name__) 

24 

25 

26@inject 

27class AdminLoginAttemptService: 

28 """Manages IP rate limiting and progressive account lockout for admin auth. 

29 

30 IP rate limiting uses an optional CacheBackendProtocol. If cache is 

31 unavailable (None), rate limiting is gracefully skipped (fail open). 

32 Account lockout is DB-persisted and survives restarts. 

33 """ 

34 

35 def __init__( 

36 self, 

37 attempt_store: AdminLoginAttemptStoreProtocol, 

38 lockout_store: AdminAccountLockoutStoreProtocol, 

39 cache: CacheBackendProtocol | None = None, 

40 ip_rate_limit_enabled: bool = True, 

41 ip_limit_per_minute: int = 10, 

42 ip_limit_per_15_minutes: int = 30, 

43 ip_limit_per_hour: int = 60, 

44 lockout_thresholds: list[tuple[int, int]] | None = None, 

45 permanent_lockout_threshold: int = 50, 

46 audit_service: AdminAuditLogServiceProtocol | None = None, 

47 ) -> None: 

48 """Initialize with stores and configuration. 

49 

50 Args: 

51 attempt_store: Store for login attempt records. 

52 lockout_store: Store for account lockout records. 

53 cache: Optional cache backend for IP rate limiting. 

54 ip_rate_limit_enabled: Whether IP rate limiting is active. 

55 ip_limit_per_minute: Max failures per IP per minute. 

56 ip_limit_per_15_minutes: Max failures per IP per 15 minutes. 

57 ip_limit_per_hour: Max failures per IP per hour. 

58 lockout_thresholds: List of (failure_count, lockout_minutes) pairs. 

59 Defaults to [(5, 15), (10, 60), (15, 240), (20, 1440)]. 

60 permanent_lockout_threshold: Failures before permanent lockout. 

61 """ 

62 self._attempt_store = attempt_store 

63 self._lockout_store = lockout_store 

64 self._cache = cache 

65 self._ip_rate_limit_enabled = ip_rate_limit_enabled 

66 self._ip_limit_per_minute = ip_limit_per_minute 

67 self._ip_limit_per_15_minutes = ip_limit_per_15_minutes 

68 self._ip_limit_per_hour = ip_limit_per_hour 

69 self._lockout_thresholds = lockout_thresholds or [ 

70 (5, 15), 

71 (10, 60), 

72 (15, 240), 

73 (20, 1440), 

74 ] 

75 self._permanent_lockout_threshold = permanent_lockout_threshold 

76 self._audit_service = audit_service 

77 

78 async def check_ip_rate_limit(self, ip_address: str) -> None: 

79 """Check if IP is rate-limited. Raises RateLimitExceededError if exceeded. 

80 

81 Uses cache for fast lookups. Gracefully skips if cache is unavailable. 

82 

83 Args: 

84 ip_address: Client IP address. 

85 

86 Raises: 

87 RateLimitExceededError: When the IP exceeds any rate limit tier. 

88 """ 

89 if not self._ip_rate_limit_enabled or self._cache is None: 

90 return 

91 

92 # Hash IP to avoid PII in Redis KEYS output 

93 ip_hash = hashlib.sha256(ip_address.encode()).hexdigest()[:16] 

94 

95 try: 

96 # Check three tiers using the DB-backed attempt store for accuracy 

97 # (Cache is used for hard blocks only — set by prior blocked requests) 

98 blocked_key = f"admin:blocked:ip:{ip_hash}" 

99 is_blocked = await self._cache.get(blocked_key) 

100 if is_blocked.is_ok() and is_blocked.unwrap(): 

101 logger.warning("ip_rate_limit.hard_blocked", ip_hash=ip_hash) 

102 raise RateLimitExceededError( 

103 "Too many failed login attempts from this IP. Please try again later.", 

104 reason="rate_limit", 

105 ) 

106 

107 # Count recent failures from DB 

108 failures_1min = await self._attempt_store.count_recent_failures_by_ip( 

109 ip_address, 60 

110 ) 

111 failures_15min = await self._attempt_store.count_recent_failures_by_ip( 

112 ip_address, 900 

113 ) 

114 failures_1hr = await self._attempt_store.count_recent_failures_by_ip( 

115 ip_address, 3600 

116 ) 

117 

118 if failures_1min >= self._ip_limit_per_minute: 

119 # Set a 5-minute hard block in cache 

120 await self._cache.set(blocked_key, "1", ttl=300) 

121 logger.warning( 

122 "ip_rate_limit.exceeded_minute", 

123 ip_hash=ip_hash, 

124 count=failures_1min, 

125 ) 

126 raise RateLimitExceededError( 

127 "Too many login attempts. Please wait 5 minutes before trying again.", 

128 retry_after=300, 

129 reason="rate_limit", 

130 ) 

131 

132 if failures_15min >= self._ip_limit_per_15_minutes: 

133 await self._cache.set(blocked_key, "1", ttl=900) 

134 logger.warning( 

135 "ip_rate_limit.exceeded_15min", 

136 ip_hash=ip_hash, 

137 count=failures_15min, 

138 ) 

139 raise RateLimitExceededError( 

140 "Too many login attempts. Please wait 15 minutes before trying again.", 

141 retry_after=900, 

142 reason="rate_limit", 

143 ) 

144 

145 if failures_1hr >= self._ip_limit_per_hour: 

146 await self._cache.set(blocked_key, "1", ttl=3600) 

147 logger.warning( 

148 "ip_rate_limit.exceeded_hour", 

149 ip_hash=ip_hash, 

150 count=failures_1hr, 

151 ) 

152 raise RateLimitExceededError( 

153 "Too many login attempts. Please try again in 1 hour.", 

154 retry_after=3600, 

155 reason="rate_limit", 

156 ) 

157 

158 except RateLimitExceededError: 

159 raise 

160 except Exception: # noqa: BLE001 

161 # Cache is unavailable — fail open (never block auth due to cache failure) 

162 logger.warning("ip_rate_limit.cache_unavailable", ip_hash=ip_hash) 

163 

164 async def check_account_lockout(self, email: str) -> None: 

165 """Check account lockout. Raises AccountLockedError if locked. 

166 

167 Args: 

168 email: Email to check. 

169 

170 Raises: 

171 AccountLockedError: When the account is locked. 

172 """ 

173 lockout_info = await self._lockout_store.get_active_lockout(email) 

174 if lockout_info is None: 

175 return 

176 

177 if lockout_info.status == AdminLockoutStatus.PERMANENT: 

178 raise AccountLockedError( 

179 "This account has been permanently locked. Please contact an administrator.", 

180 reason="lockout", 

181 ) 

182 

183 if lockout_info.status == AdminLockoutStatus.LOCKED: 

184 if lockout_info.unlock_at: 

185 retry_after = max( 

186 0, 

187 int((lockout_info.unlock_at - datetime.now(UTC)).total_seconds()), 

188 ) 

189 raise AccountLockedError( 

190 f"Account temporarily locked due to too many failed attempts. " 

191 f"Please try again after {lockout_info.unlock_at.strftime('%H:%M UTC')}.", 

192 unlock_at=lockout_info.unlock_at, 

193 retry_after=retry_after, 

194 reason="lockout", 

195 ) 

196 raise AccountLockedError( 

197 "Account is temporarily locked. Please try again later.", 

198 reason="lockout", 

199 ) 

200 

201 async def record_attempt( 

202 self, 

203 email: str, 

204 ip_address: str, 

205 user_agent: str, 

206 success: bool, 

207 failure_reason: str | None = None, 

208 ) -> None: 

209 """Record a login attempt and update lockout state on failure. 

210 

211 On success, lockout counters are NOT cleared here — use clear_lockout(). 

212 On failure, checks thresholds and creates/updates lockout record. 

213 

214 Args: 

215 email: Email that attempted login. 

216 ip_address: Client IP. 

217 user_agent: Client user agent. 

218 success: Whether the attempt succeeded. 

219 failure_reason: Short failure code when success=False. 

220 """ 

221 from datetime import timedelta 

222 import uuid 

223 

224 attempt = AdminLoginAttempt( 

225 id=str(uuid.uuid4()), 

226 email=email, 

227 ip_address=ip_address, 

228 user_agent=user_agent, 

229 success=success, 

230 failure_reason=failure_reason, 

231 attempted_at=datetime.now(UTC), 

232 ) 

233 await self._attempt_store.insert(attempt) 

234 

235 if success: 

236 return 

237 

238 # Count total consecutive failures to determine lockout level 

239 total_failures = await self._attempt_store.count_recent_failures(email, 86400) 

240 

241 if total_failures >= self._permanent_lockout_threshold: 

242 await self._lockout_store.create_lockout( 

243 email=email, 

244 consecutive_failures=total_failures, 

245 unlock_at=None, 

246 is_permanent=True, 

247 ) 

248 logger.warning( 

249 "account_lockout.permanent", 

250 email_hash=hashlib.sha256(email.encode()).hexdigest()[:8], 

251 ) 

252 return 

253 

254 # Find applicable lockout threshold 

255 lockout_minutes: int | None = None 

256 for threshold_failures, minutes in sorted( 

257 self._lockout_thresholds, reverse=True 

258 ): 

259 if total_failures >= threshold_failures: 

260 lockout_minutes = minutes 

261 break 

262 

263 if lockout_minutes is not None: 

264 unlock_at = datetime.now(UTC) + timedelta(minutes=lockout_minutes) 

265 await self._lockout_store.create_lockout( 

266 email=email, 

267 consecutive_failures=total_failures, 

268 unlock_at=unlock_at, 

269 is_permanent=False, 

270 ) 

271 logger.warning( 

272 "account_lockout.temporary", 

273 email_hash=hashlib.sha256(email.encode()).hexdigest()[:8], 

274 minutes=lockout_minutes, 

275 failures=total_failures, 

276 ) 

277 

278 async def clear_lockout(self, email: str) -> None: 

279 """Clear lockout and failure records on successful login. 

280 

281 Audits an ``ACCOUNT_UNLOCKED`` event when an active lockout existed. 

282 

283 Args: 

284 email: Email to clear. 

285 """ 

286 active = await self._lockout_store.get_active_lockout(email) 

287 await self._lockout_store.clear_lockout(email) 

288 await self._attempt_store.clear_failures(email) 

289 if active and self._audit_service: 

290 await self._audit_service.log_event( 

291 event_type=AdminSecurityEventType.ACCOUNT_UNLOCKED, 

292 ip_address="", 

293 user_agent="", 

294 success=True, 

295 metadata={"email": email}, 

296 ) 

297 logger.debug( 

298 "account_lockout.cleared", 

299 email_hash=hashlib.sha256(email.encode()).hexdigest()[:8], 

300 ) 

301 

302 

303__all__ = ["AdminLoginAttemptService"]