132 lines
4.2 KiB
Python
132 lines
4.2 KiB
Python
from datetime import datetime
|
|
from itertools import count
|
|
|
|
from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User
|
|
from gapido_auth.domain.errors import OtpAttemptsExceeded, OtpExpired
|
|
|
|
|
|
class FakeUserRepository:
|
|
def __init__(self) -> None:
|
|
self._users_by_mobile: dict[str, User] = {}
|
|
self._ids = count(1)
|
|
|
|
async def get_by_id(self, user_id: str) -> User | None:
|
|
return next((user for user in self._users_by_mobile.values() if user.id == user_id), None)
|
|
|
|
async def get_by_mobile(self, mobile: str) -> User | None:
|
|
return self._users_by_mobile.get(mobile)
|
|
|
|
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User:
|
|
existing = self._users_by_mobile.get(mobile)
|
|
if existing:
|
|
return existing
|
|
now = datetime.now()
|
|
user = User(
|
|
id=str(next(self._ids)),
|
|
mobile=mobile,
|
|
role=role,
|
|
is_active=True,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
self._users_by_mobile[mobile] = user
|
|
return user
|
|
|
|
async def ensure_admin_user(self, mobile: str) -> User:
|
|
now = datetime.now()
|
|
user = User(
|
|
id=str(next(self._ids)),
|
|
mobile=mobile,
|
|
role=Role.ADMIN,
|
|
is_active=True,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
self._users_by_mobile[mobile] = user
|
|
return user
|
|
|
|
|
|
class FakeRefreshSessionRepository:
|
|
def __init__(self) -> None:
|
|
self._sessions: dict[str, RefreshSession] = {}
|
|
self._ids = count(1)
|
|
|
|
async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession:
|
|
session = RefreshSession(
|
|
id=str(next(self._ids)),
|
|
user_id=user_id,
|
|
token_hash=token_hash,
|
|
expires_at=expires_at,
|
|
revoked_at=None,
|
|
replaced_by_hash=None,
|
|
created_at=datetime.now(),
|
|
)
|
|
self._sessions[token_hash] = session
|
|
return session
|
|
|
|
async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None:
|
|
session = self._sessions.get(token_hash)
|
|
if session is None or session.revoked_at is not None or session.expires_at <= now:
|
|
return None
|
|
return session
|
|
|
|
async def revoke(
|
|
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
|
) -> None:
|
|
session = self._sessions.get(token_hash)
|
|
if session is None or session.revoked_at is not None:
|
|
return
|
|
self._sessions[token_hash] = RefreshSession(
|
|
id=session.id,
|
|
user_id=session.user_id,
|
|
token_hash=session.token_hash,
|
|
expires_at=session.expires_at,
|
|
revoked_at=now,
|
|
replaced_by_hash=replaced_by_hash,
|
|
created_at=session.created_at,
|
|
)
|
|
|
|
|
|
class FakeOtpStore:
|
|
def __init__(self) -> None:
|
|
self.requests: dict[str, int] = {}
|
|
self.otps: dict[tuple[str, str], tuple[str, int, int]] = {}
|
|
|
|
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool:
|
|
self.requests[key] = self.requests.get(key, 0) + 1
|
|
return self.requests[key] <= limit
|
|
|
|
async def store_otp(
|
|
self,
|
|
mobile: str,
|
|
purpose: str,
|
|
otp_hash: str,
|
|
ttl_seconds: int,
|
|
max_attempts: int,
|
|
) -> None:
|
|
self.otps[(mobile, purpose)] = (otp_hash, 0, max_attempts)
|
|
|
|
async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool:
|
|
key = (mobile, purpose)
|
|
if key not in self.otps:
|
|
raise OtpExpired("missing")
|
|
stored_hash, attempts, max_attempts = self.otps[key]
|
|
attempts += 1
|
|
if attempts > max_attempts:
|
|
del self.otps[key]
|
|
raise OtpAttemptsExceeded("too many")
|
|
self.otps[key] = (stored_hash, attempts, max_attempts)
|
|
if stored_hash != candidate_hash:
|
|
return False
|
|
del self.otps[key]
|
|
return True
|
|
|
|
|
|
class FakeSmsPublisher:
|
|
def __init__(self) -> None:
|
|
self.jobs: list[SmsJob] = []
|
|
|
|
async def publish(self, job: SmsJob) -> None:
|
|
self.jobs.append(job)
|
|
|