feat(infra): add persistence and SMS adapters

This commit is contained in:
2026-07-14 09:32:58 +03:30
parent 4fc0ee1107
commit 61f7b5c322
7 changed files with 440 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
from typing import Any, cast
from redis.asyncio import Redis
from gapido_auth.domain.errors import OtpAttemptsExceeded, OtpExpired
from gapido_auth.domain.ports import OtpStore
class RedisOtpStore(OtpStore):
def __init__(self, redis: Redis) -> None:
self._redis = redis
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool:
count = await self._redis.incr(key)
if count == 1:
await self._redis.expire(key, window_seconds)
return int(count) <= limit
async def store_otp(
self,
mobile: str,
purpose: str,
otp_hash: str,
ttl_seconds: int,
max_attempts: int,
) -> None:
key = self._otp_key(mobile, purpose)
attempts_key = self._attempts_key(mobile, purpose)
async with self._redis.pipeline(transaction=True) as pipe:
pipe.hset(key, mapping={"hash": otp_hash, "max_attempts": str(max_attempts)})
pipe.expire(key, ttl_seconds)
pipe.delete(attempts_key)
pipe.expire(attempts_key, ttl_seconds)
await pipe.execute()
async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool:
key = self._otp_key(mobile, purpose)
attempts_key = self._attempts_key(mobile, purpose)
stored_hash = await cast(Any, self._redis.hget(key, "hash"))
if stored_hash is None:
raise OtpExpired("OTP expired or was not requested")
max_attempts_raw = await cast(Any, self._redis.hget(key, "max_attempts"))
max_attempts = int(max_attempts_raw or 5)
attempts = int(await self._redis.incr(attempts_key))
ttl = await self._redis.ttl(key)
if ttl > 0:
await self._redis.expire(attempts_key, ttl)
if attempts > max_attempts:
await self._redis.delete(key)
raise OtpAttemptsExceeded("OTP attempts exceeded")
expected = stored_hash.decode() if isinstance(stored_hash, bytes) else str(stored_hash)
if expected != candidate_hash:
return False
await self._redis.delete(key, attempts_key)
return True
@staticmethod
def _otp_key(mobile: str, purpose: str) -> str:
return f"otp:{mobile}:{purpose}"
@staticmethod
def _attempts_key(mobile: str, purpose: str) -> str:
return f"otp-attempts:{mobile}:{purpose}"