Coverage for src/lexigram/admin/auth/services/email_verification_service.py: 93%

91 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Admin email verification orchestration service. 

2 

3Handles the verify-your-email flow: issuing single-use verification links, 

4delivering them through the admin notification service, and consuming the 

5links. Depends only on ``AdminEmailVerificationStoreProtocol`` from 

6``lexigram.admin.auth.protocols``. 

7""" 

8 

9from __future__ import annotations 

10 

11from datetime import UTC, datetime, timedelta 

12import hashlib 

13import secrets 

14from typing import TYPE_CHECKING 

15 

16from lexigram.admin.auth.errors import ( 

17 AdminAuthError, 

18 EmailVerificationTokenInvalidError, 

19 RateLimitExceededError, 

20) 

21from lexigram.admin.auth.types import AdminSecurityEventType 

22from lexigram.admin.config import AdminEmailVerificationConfig 

23from lexigram.contracts.infra.cache import CacheBackendProtocol 

24from lexigram.di.decorators import inject 

25from lexigram.logging import get_logger 

26from lexigram.result import Err, Ok, Result 

27 

28if TYPE_CHECKING: 

29 from lexigram.admin.auth.protocols import ( 

30 AdminAuditLogServiceProtocol, 

31 AdminEmailVerificationStoreProtocol, 

32 ) 

33 from lexigram.admin.services.notifications import AdminNotificationService 

34 

35logger = get_logger(__name__) 

36 

37 

38@inject 

39class AdminEmailVerificationService: 

40 """Email verification flow: links, delivery, and consumption. 

41 

42 A verification link embeds a random token; the store keeps only its 

43 sha256 digest. Delivery failures are surfaced: when no notification 

44 service (or mailer backend) is bound, sending fails with a descriptive 

45 error telling the operator which dependency to configure, so missing 

46 infrastructure is never silently swallowed. 

47 """ 

48 

49 def __init__( 

50 self, 

51 config: AdminEmailVerificationConfig, 

52 store: AdminEmailVerificationStoreProtocol, 

53 notification_service: AdminNotificationService | None = None, 

54 audit_service: AdminAuditLogServiceProtocol | None = None, 

55 cache: CacheBackendProtocol | None = None, 

56 resend_request_limit: int = 5, 

57 resend_window_seconds: int = 3600, 

58 ) -> None: 

59 """Initialise the verification service. 

60 

61 Args: 

62 config: Email verification settings. 

63 store: Verification state persistence. 

64 notification_service: Optional delivery channel for the 

65 verification email (skipped when None). 

66 audit_service: Optional security audit logger. 

67 cache: Optional cache backend for per-IP resend rate limiting; 

68 ``None`` (or a failing cache) skips limiting (fail open). 

69 resend_request_limit: Max resend requests per IP per window. 

70 resend_window_seconds: Rate-limit window length in seconds. 

71 """ 

72 self._config = config 

73 self._store = store 

74 self._notification_service = notification_service 

75 self._audit_service = audit_service 

76 self._cache = cache 

77 self._resend_request_limit = resend_request_limit 

78 self._resend_window_seconds = resend_window_seconds 

79 

80 async def is_verified(self, user_id: str) -> bool: 

81 """Return True when the user's email is verified. 

82 

83 Args: 

84 user_id: Admin user UUID. 

85 """ 

86 return await self._store.is_verified(user_id) 

87 

88 async def is_required(self, user_id: str) -> bool: 

89 """Return True when login must be gated on email verification. 

90 

91 Args: 

92 user_id: Admin user UUID. 

93 """ 

94 if not self._config.enabled or not self._config.enforcement: 

95 return False 

96 return not await self._store.is_verified(user_id) 

97 

98 async def send_verification( 

99 self, 

100 user_id: str, 

101 email: str, 

102 user_name: str, 

103 base_url: str = "", 

104 ip_address: str = "", 

105 ) -> Result[None, AdminAuthError]: 

106 """Issue a verification link and email it to the user. 

107 

108 No-op (Ok) when the flow is disabled or the email is already 

109 verified. Delivery failures are NOT swallowed: a missing 

110 notification service or a failed delivery returns 

111 ``Err(AdminAuthError)`` with guidance on the missing mailer 

112 dependency. Resend requests are rate limited per IP when a cache 

113 backend is wired (fail open). 

114 

115 Args: 

116 user_id: Admin user UUID. 

117 email: Email address to verify. 

118 user_name: Display name for the email greeting. 

119 base_url: Origin used to build the absolute verify link 

120 (e.g. ``https://panel.example.com``). 

121 ip_address: Client IP for resend rate limiting. 

122 

123 Returns: 

124 ``Ok(None)`` when the link was issued and delivered. 

125 ``Err(RateLimitExceededError)`` when this IP exceeds the 

126 resend limit. 

127 ``Err(AdminAuthError)`` when no notification service is bound 

128 or email delivery failed (e.g. no mailer backend configured). 

129 """ 

130 if not self._config.enabled or await self._store.is_verified(user_id): 

131 return Ok(None) 

132 

133 if self._cache is not None and await self._is_rate_limited(ip_address): 

134 logger.warning("email_verification_rate_limited", ip=ip_address) 

135 return Err( 

136 RateLimitExceededError( 

137 "Too many verification emails. Please try again later.", 

138 reason="rate_limit", 

139 ) 

140 ) 

141 

142 token = secrets.token_urlsafe(32) 

143 token_hash = hashlib.sha256(token.encode()).hexdigest() 

144 expires_at = datetime.now(UTC) + timedelta(hours=self._config.token_ttl_hours) 

145 await self._store.save_token(user_id, token_hash, expires_at) 

146 

147 verify_url = f"{base_url.rstrip('/')}/admin/verify-email/{token}" 

148 

149 if self._notification_service is None: 

150 logger.error( 

151 "email_verification_skipped", 

152 user_id=user_id, 

153 email=email, 

154 verify_url=verify_url, 

155 ) 

156 if self._audit_service is not None: 

157 await self._audit_service.log_event( 

158 event_type=AdminSecurityEventType.EMAIL_VERIFICATION_SENT, 

159 ip_address="", 

160 user_agent="", 

161 success=False, 

162 admin_user_id=user_id, 

163 metadata={"email": email, "reason": "no_notification_service"}, 

164 ) 

165 return Err( 

166 AdminAuthError( 

167 "Verification email could not be delivered because no " 

168 "notification/mailer dependency is configured. Configure a " 

169 "mailer backend (lexigram-notification MailerModule with " 

170 "driver 'smtp'/'sendgrid', or 'console' in development) " 

171 "and retry.", 

172 ) 

173 ) 

174 

175 result = await self._notification_service.notify_email_verification( 

176 user_email=email, 

177 user_name=user_name, 

178 verify_url=verify_url, 

179 expires_in=f"{self._config.token_ttl_hours} hours", 

180 ) 

181 if result.is_err(): 

182 logger.error( 

183 "email_verification_send_failed", 

184 user_id=user_id, 

185 email=email, 

186 error=str(result.unwrap_err()), 

187 verify_url=verify_url, 

188 ) 

189 if self._audit_service is not None: 

190 await self._audit_service.log_event( 

191 event_type=AdminSecurityEventType.EMAIL_VERIFICATION_SENT, 

192 ip_address="", 

193 user_agent="", 

194 success=False, 

195 admin_user_id=user_id, 

196 metadata={"email": email, "reason": str(result.unwrap_err())}, 

197 ) 

198 return Err( 

199 AdminAuthError( 

200 "Verification email could not be delivered: " 

201 f"{result.unwrap_err()} Configure a mailer backend " 

202 "(lexigram-notification MailerModule with driver " 

203 "'smtp'/'sendgrid', or 'console' in development) and " 

204 "retry.", 

205 ) 

206 ) 

207 

208 if self._audit_service is not None: 

209 await self._audit_service.log_event( 

210 event_type=AdminSecurityEventType.EMAIL_VERIFICATION_SENT, 

211 ip_address="", 

212 user_agent="", 

213 success=True, 

214 admin_user_id=user_id, 

215 metadata={"email": email}, 

216 ) 

217 return Ok(None) 

218 

219 async def verify_token(self, token: str) -> Result[bool, AdminAuthError]: 

220 """Validate and consume a verification token. 

221 

222 Args: 

223 token: Raw token from the emailed link. 

224 

225 Returns: 

226 ``Ok(True)`` when the token was valid and the email is now 

227 verified; ``Err(EmailVerificationTokenInvalidError)`` when the 

228 token is unknown, used, or expired. 

229 """ 

230 token_hash = hashlib.sha256(token.encode()).hexdigest() 

231 user_id = await self._store.find_user_by_token_hash(token_hash) 

232 

233 if user_id is None: 

234 await self._audit_failure("no_such_token") 

235 return Err( 

236 EmailVerificationTokenInvalidError( 

237 "Invalid or expired verification link." 

238 ) 

239 ) 

240 

241 consumed = await self._store.consume_token(user_id, token_hash) 

242 if not consumed: 

243 await self._audit_failure("consumed_or_expired", user_id) 

244 return Err( 

245 EmailVerificationTokenInvalidError( 

246 "Invalid or expired verification link." 

247 ) 

248 ) 

249 

250 if self._audit_service is not None: 

251 await self._audit_service.log_event( 

252 event_type=AdminSecurityEventType.EMAIL_VERIFIED, 

253 ip_address="", 

254 user_agent="", 

255 success=True, 

256 admin_user_id=user_id, 

257 ) 

258 return Ok(True) 

259 

260 async def resend_verification( 

261 self, 

262 user_id: str, 

263 email: str, 

264 user_name: str, 

265 base_url: str = "", 

266 ip_address: str = "", 

267 ) -> Result[None, AdminAuthError]: 

268 """Re-issue and re-send the verification email. 

269 

270 Args: 

271 user_id: Admin user UUID. 

272 email: Email address to verify. 

273 user_name: Display name for the email greeting. 

274 base_url: Origin used to build the absolute verify link. 

275 ip_address: Client IP for resend rate limiting. 

276 

277 Returns: 

278 ``Ok(None)`` on success or when the flow is disabled/verified. 

279 """ 

280 return await self.send_verification( 

281 user_id, email, user_name, base_url, ip_address 

282 ) 

283 

284 async def _is_rate_limited(self, ip_address: str) -> bool: 

285 """Check and increment the per-IP resend counter. Fail open. 

286 

287 Uses a fixed-window counter keyed by a sha256 hash of the client IP 

288 (avoids PII in cache key listings). Any cache failure is treated as 

289 "not limited" so a cache outage never blocks verification emails. 

290 

291 Args: 

292 ip_address: Client IP address. 

293 

294 Returns: 

295 ``True`` when the IP exceeds ``resend_request_limit``. 

296 """ 

297 try: 

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

299 key = f"admin:email-verification:ip:{ip_hash}" 

300 cache = self._cache 

301 if cache is None: 

302 return False 

303 result = await cache.get(key) 

304 value = result.unwrap() if result.is_ok() else None 

305 count = int(value) if value else 0 

306 if count >= self._resend_request_limit: 

307 return True 

308 await cache.set(key, str(count + 1), ttl=self._resend_window_seconds) 

309 return False 

310 except Exception: # noqa: BLE001 — fail open on cache outages 

311 logger.warning("email_verification_rate_limit_unavailable") 

312 return False 

313 

314 async def _audit_failure(self, reason: str, user_id: str | None = None) -> None: 

315 """Record a failed verification attempt (never raises).""" 

316 if self._audit_service is None: 

317 return 

318 await self._audit_service.log_event( 

319 event_type=AdminSecurityEventType.EMAIL_VERIFICATION_FAILED, 

320 ip_address="", 

321 user_agent="", 

322 success=False, 

323 admin_user_id=user_id, 

324 metadata={"reason": reason}, 

325 ) 

326 

327 

328__all__ = ["AdminEmailVerificationService"]