162 lines
5.9 KiB
Python
162 lines
5.9 KiB
Python
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from bson import ObjectId
|
|
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
from pymongo import ASCENDING
|
|
|
|
from gapido_auth.domain.entities import RefreshSession, Role, User
|
|
from gapido_auth.domain.ports import RefreshSessionRepository, UserRepository
|
|
|
|
|
|
def _now() -> datetime:
|
|
"""Return timezone-aware UTC now for Mongo document timestamps."""
|
|
return datetime.now(UTC)
|
|
|
|
|
|
def _user_from_doc(doc: dict[str, Any]) -> User:
|
|
"""Map a Mongo user document into the domain entity."""
|
|
return User(
|
|
id=str(doc["_id"]),
|
|
mobile=str(doc["mobile"]),
|
|
role=Role(str(doc["role"])),
|
|
is_active=bool(doc["is_active"]),
|
|
created_at=doc["created_at"],
|
|
updated_at=doc["updated_at"],
|
|
)
|
|
|
|
|
|
def _session_from_doc(doc: dict[str, Any]) -> RefreshSession:
|
|
"""Map a Mongo refresh-session document into the domain entity."""
|
|
return RefreshSession(
|
|
id=str(doc["_id"]),
|
|
user_id=str(doc["user_id"]),
|
|
token_hash=str(doc["token_hash"]),
|
|
expires_at=doc["expires_at"],
|
|
revoked_at=doc.get("revoked_at"),
|
|
replaced_by_hash=doc.get("replaced_by_hash"),
|
|
created_at=doc["created_at"],
|
|
)
|
|
|
|
|
|
class MongoUserRepository(UserRepository):
|
|
"""MongoDB adapter for user documents and admin bootstrap."""
|
|
|
|
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
|
"""Bind the repository to the users collection."""
|
|
self._collection = db.users
|
|
|
|
async def create_indexes(self) -> None:
|
|
"""Create indexes required for unique mobile lookup and role scans."""
|
|
|
|
await self._collection.create_index([("mobile", ASCENDING)], unique=True)
|
|
await self._collection.create_index([("role", ASCENDING)])
|
|
|
|
async def get_by_id(self, user_id: str) -> User | None:
|
|
"""Return a user by Mongo ObjectId string, or None for invalid/missing ids."""
|
|
|
|
if not ObjectId.is_valid(user_id):
|
|
return None
|
|
doc = await self._collection.find_one({"_id": ObjectId(user_id)})
|
|
return _user_from_doc(doc) if doc else None
|
|
|
|
async def get_by_mobile(self, mobile: str) -> User | None:
|
|
"""Return the user registered for a mobile number if one exists."""
|
|
|
|
doc = await self._collection.find_one({"mobile": mobile})
|
|
return _user_from_doc(doc) if doc else None
|
|
|
|
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User:
|
|
"""Create a default active user for a verified mobile, or return the existing one."""
|
|
|
|
now = _now()
|
|
await self._collection.update_one(
|
|
{"mobile": mobile},
|
|
{
|
|
"$setOnInsert": {
|
|
"mobile": mobile,
|
|
"role": role.value,
|
|
"is_active": True,
|
|
"created_at": now,
|
|
},
|
|
"$set": {"updated_at": now},
|
|
},
|
|
upsert=True,
|
|
)
|
|
user = await self.get_by_mobile(mobile)
|
|
if user is None:
|
|
raise RuntimeError("failed to create user")
|
|
return user
|
|
|
|
async def ensure_admin_user(self, mobile: str) -> User:
|
|
"""Idempotently seed or promote the configured admin mobile."""
|
|
|
|
now = _now()
|
|
await self._collection.update_one(
|
|
{"mobile": mobile},
|
|
{
|
|
"$set": {"role": Role.ADMIN.value, "is_active": True, "updated_at": now},
|
|
"$setOnInsert": {"mobile": mobile, "created_at": now},
|
|
},
|
|
upsert=True,
|
|
)
|
|
user = await self.get_by_mobile(mobile)
|
|
if user is None:
|
|
raise RuntimeError("failed to seed admin user")
|
|
return user
|
|
|
|
|
|
class MongoRefreshSessionRepository(RefreshSessionRepository):
|
|
"""MongoDB adapter for hashed refresh-token sessions."""
|
|
|
|
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
|
"""Bind the repository to the refresh_sessions collection."""
|
|
self._collection = db.refresh_sessions
|
|
|
|
async def create_indexes(self) -> None:
|
|
"""Create indexes used for token lookup, user session scans, and revocation."""
|
|
|
|
await self._collection.create_index([("token_hash", ASCENDING)], unique=True)
|
|
await self._collection.create_index([("user_id", ASCENDING), ("expires_at", ASCENDING)])
|
|
await self._collection.create_index([("revoked_at", ASCENDING)])
|
|
|
|
async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession:
|
|
"""Persist a new active refresh session for a user."""
|
|
|
|
now = _now()
|
|
result = await self._collection.insert_one(
|
|
{
|
|
"user_id": user_id,
|
|
"token_hash": token_hash,
|
|
"expires_at": expires_at,
|
|
"revoked_at": None,
|
|
"replaced_by_hash": None,
|
|
"created_at": now,
|
|
}
|
|
)
|
|
doc = await self._collection.find_one({"_id": result.inserted_id})
|
|
if doc is None:
|
|
raise RuntimeError("failed to create refresh session")
|
|
return _session_from_doc(doc)
|
|
|
|
async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None:
|
|
"""Return a non-revoked, non-expired session by token hash."""
|
|
|
|
doc = await self._collection.find_one(
|
|
{"token_hash": token_hash, "revoked_at": None, "expires_at": {"$gt": now}}
|
|
)
|
|
return _session_from_doc(doc) if doc else None
|
|
|
|
async def revoke(
|
|
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
|
) -> None:
|
|
"""Mark a refresh session revoked, optionally linking its replacement hash."""
|
|
|
|
update: dict[str, Any] = {"revoked_at": now}
|
|
if replaced_by_hash is not None:
|
|
update["replaced_by_hash"] = replaced_by_hash
|
|
await self._collection.update_one(
|
|
{"token_hash": token_hash, "revoked_at": None},
|
|
{"$set": update},
|
|
)
|