diff --git a/src/gapido_auth/application/auth_service.py b/src/gapido_auth/application/auth_service.py index 261bf38..b01a291 100644 --- a/src/gapido_auth/application/auth_service.py +++ b/src/gapido_auth/application/auth_service.py @@ -28,6 +28,8 @@ from gapido_auth.domain.ports import ( @dataclass(frozen=True, slots=True) class AuthConfig: + """Runtime policy values used by auth use cases.""" + otp_secret: str otp_ttl_seconds: int otp_max_attempts: int @@ -38,6 +40,8 @@ class AuthConfig: class AuthService: + """Application service coordinating OTP login, token rotation, and RBAC.""" + def __init__( self, users: UserRepository, @@ -47,6 +51,7 @@ class AuthService: token_codec: JwtTokenCodec, config: AuthConfig, ) -> None: + """Wire repository, queue, OTP, and token ports for use-case execution.""" self._users = users self._refresh_sessions = refresh_sessions self._otp_store = otp_store @@ -55,6 +60,8 @@ class AuthService: self._config = config async def request_otp(self, mobile: str, purpose: str, client_key: str) -> None: + """Validate a request, store an OTP hash, and queue SMS delivery.""" + _validate_mobile(mobile) _validate_purpose(purpose) mobile_key = f"otp-request:mobile:{mobile}:{purpose}" @@ -73,6 +80,7 @@ class AuthService: raise RateLimitExceeded("too many OTP requests") code = generate_otp_code() + # Store only a bound HMAC hash; the plaintext code leaves through the SMS queue only. otp_hash = hash_otp(self._config.otp_secret, mobile, purpose, code) await self._otp_store.store_otp( mobile=mobile, @@ -86,6 +94,8 @@ class AuthService: ) async def verify_otp(self, mobile: str, code: str, purpose: str) -> TokenPair: + """Verify the submitted OTP and issue a token pair for the mobile identity.""" + _validate_mobile(mobile) _validate_purpose(purpose) if not code.isdigit() or len(code) != 6: @@ -100,6 +110,8 @@ class AuthService: return await self._issue_token_pair(user) async def refresh_token(self, refresh_token: str) -> TokenPair: + """Rotate a valid refresh token and return a new access/refresh pair.""" + token_hash = hash_refresh_token(refresh_token) now = utc_now() session = await self._refresh_sessions.get_active_by_hash(token_hash, now) @@ -110,6 +122,7 @@ class AuthService: if user is None or not user.is_active: raise AuthenticationError("invalid refresh token") + # Rotation revokes the old token hash and persists a fresh session hash. new_refresh_token = generate_refresh_token() new_hash = hash_refresh_token(new_refresh_token) expires_at = now + timedelta(seconds=self._config.refresh_token_ttl_seconds) @@ -125,9 +138,13 @@ class AuthService: ) async def revoke_refresh_token(self, refresh_token: str) -> None: + """Revoke a refresh token session if it is still active.""" + await self._refresh_sessions.revoke(hash_refresh_token(refresh_token), utc_now()) async def get_authenticated_user(self, access_token: str) -> User: + """Resolve an access token into an active user entity.""" + claims = self._token_codec.decode_access_token(access_token) user = await self._users.get_by_id(claims.user_id) if user is None: @@ -137,12 +154,16 @@ class AuthService: return user async def require_role(self, access_token: str, role: Role) -> User: + """Resolve a user and ensure the requested role is present.""" + user = await self.get_authenticated_user(access_token) if user.role != role: raise PermissionDenied("insufficient permissions") return user async def _issue_token_pair(self, user: User) -> TokenPair: + """Create a JWT access token and persisted refresh session for a user.""" + if not user.is_active: raise InactiveUser("user is inactive") @@ -160,11 +181,15 @@ class AuthService: def _validate_mobile(mobile: str) -> None: + """Validate the E.164-like mobile format accepted by the challenge service.""" + normalized = mobile.removeprefix("+") if not normalized.isdigit() or len(normalized) < 10 or len(normalized) > 15: raise ValidationError("mobile must be an E.164-like phone number") def _validate_purpose(purpose: str) -> None: + """Validate the OTP purpose used to namespace OTP hashes and rate limits.""" + if not purpose or not purpose.replace("-", "").replace("_", "").isalnum(): raise ValidationError("purpose is invalid") diff --git a/src/gapido_auth/application/security.py b/src/gapido_auth/application/security.py index d1780da..eb79c48 100644 --- a/src/gapido_auth/application/security.py +++ b/src/gapido_auth/application/security.py @@ -11,37 +11,54 @@ from gapido_auth.domain.errors import AuthenticationError def utc_now() -> datetime: + """Return timezone-aware UTC time for token/session timestamps.""" + return datetime.now(UTC) def generate_otp_code() -> str: + """Generate a cryptographically random six-digit OTP string.""" + return f"{secrets.randbelow(1_000_000):06d}" def hash_otp(secret: str, mobile: str, purpose: str, code: str) -> str: + """Bind an OTP to its mobile and purpose before storing only an HMAC hash.""" + message = f"{mobile}:{purpose}:{code}".encode() return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() def generate_refresh_token() -> str: + """Generate an opaque refresh token suitable for returning to clients.""" + return secrets.token_urlsafe(48) def hash_refresh_token(token: str) -> str: + """Hash a refresh token before persistence so plaintext tokens are never stored.""" + return hashlib.sha256(token.encode()).hexdigest() class JwtTokenCodec: + """Encode and validate short-lived access JWTs for authenticated gRPC calls.""" + def __init__(self, secret_key: str, issuer: str, access_ttl_seconds: int) -> None: + """Store signing configuration used for all access-token operations.""" self._secret_key = secret_key self._issuer = issuer self._access_ttl_seconds = access_ttl_seconds @property def access_ttl_seconds(self) -> int: + """Return the configured access-token TTL exposed to clients.""" + return self._access_ttl_seconds def encode_access_token(self, user_id: str, role: Role) -> str: + """Create a signed access JWT containing user identity, role, and expiry.""" + now = utc_now() expires_at = now + timedelta(seconds=self._access_ttl_seconds) payload = { @@ -56,6 +73,8 @@ class JwtTokenCodec: return jwt.encode(payload, self._secret_key, algorithm="HS256") def decode_access_token(self, token: str) -> AccessClaims: + """Validate an access JWT and return typed claims used by RBAC checks.""" + try: payload = jwt.decode( token, @@ -80,4 +99,3 @@ class JwtTokenCodec: role=role, expires_at=datetime.fromtimestamp(int(payload["exp"]), UTC), ) - diff --git a/src/gapido_auth/config.py b/src/gapido_auth/config.py index 0d6778c..b7663ab 100644 --- a/src/gapido_auth/config.py +++ b/src/gapido_auth/config.py @@ -6,6 +6,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): + """Environment-driven settings shared by auth, worker, and demo services.""" + app_env: str = "local" mongo_uri: str = "mongodb://localhost:27017" @@ -46,4 +48,6 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: + """Return cached process settings loaded from environment and optional .env files.""" + return Settings() diff --git a/src/gapido_auth/domain/entities.py b/src/gapido_auth/domain/entities.py index 1b3a2e7..4bc0dd7 100644 --- a/src/gapido_auth/domain/entities.py +++ b/src/gapido_auth/domain/entities.py @@ -4,12 +4,16 @@ from enum import StrEnum class Role(StrEnum): + """User roles used by the auth service for access-control checks.""" + ADMIN = "admin" USER = "user" @dataclass(frozen=True, slots=True) class User: + """Persisted account identity created after a successful OTP verification.""" + id: str mobile: str role: Role @@ -20,6 +24,8 @@ class User: @dataclass(frozen=True, slots=True) class RefreshSession: + """Persisted refresh-token session stored as a token hash, never plaintext.""" + id: str user_id: str token_hash: str @@ -31,6 +37,8 @@ class RefreshSession: @dataclass(frozen=True, slots=True) class SmsJob: + """RabbitMQ payload for delivering an OTP through the configured SMS provider.""" + mobile: str code: str template: str @@ -39,6 +47,8 @@ class SmsJob: @dataclass(frozen=True, slots=True) class TokenPair: + """Access and refresh tokens returned to a client after login or refresh.""" + access_token: str refresh_token: str token_type: str @@ -48,7 +58,8 @@ class TokenPair: @dataclass(frozen=True, slots=True) class AccessClaims: + """Trusted claims extracted from a validated access JWT.""" + user_id: str role: Role expires_at: datetime - diff --git a/src/gapido_auth/domain/errors.py b/src/gapido_auth/domain/errors.py index a0c20ff..bb7dcce 100644 --- a/src/gapido_auth/domain/errors.py +++ b/src/gapido_auth/domain/errors.py @@ -3,36 +3,54 @@ class AppError(Exception): class RateLimitExceeded(AppError): + """Raised when OTP request or verification limits are exceeded.""" + pass class ValidationError(AppError): + """Raised when client input cannot be accepted by the application layer.""" + pass class InvalidOtp(AppError): + """Raised when an OTP exists but the submitted code does not match.""" + pass class OtpExpired(AppError): + """Raised when an OTP is missing because it expired or was never requested.""" + pass class OtpAttemptsExceeded(AppError): + """Raised when OTP verification attempts exceed the configured limit.""" + pass class AuthenticationError(AppError): + """Raised when credentials or tokens cannot authenticate a caller.""" + pass class PermissionDenied(AppError): + """Raised when an authenticated caller lacks the required role.""" + pass class InactiveUser(AppError): + """Raised when an existing user is disabled and cannot authenticate.""" + pass class ExternalServiceError(AppError): + """Raised when an external provider fails or returns an error response.""" + pass diff --git a/src/gapido_auth/domain/ports.py b/src/gapido_auth/domain/ports.py index 5ce5158..f308418 100644 --- a/src/gapido_auth/domain/ports.py +++ b/src/gapido_auth/domain/ports.py @@ -5,29 +5,51 @@ from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User class UserRepository(Protocol): - async def get_by_id(self, user_id: str) -> User | None: ... + """Persistence port for user lookup, creation, and admin bootstrapping.""" - async def get_by_mobile(self, mobile: str) -> User | None: ... + async def get_by_id(self, user_id: str) -> User | None: + """Return a user by internal id, or None when it does not exist.""" + ... - async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: ... + async def get_by_mobile(self, mobile: str) -> User | None: + """Return a user by mobile number, or None when it does not exist.""" + ... - async def ensure_admin_user(self, mobile: str) -> User: ... + async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: + """Return the existing user for a mobile or create one with the default role.""" + ... + + async def ensure_admin_user(self, mobile: str) -> User: + """Seed or promote the configured admin mobile idempotently.""" + ... class RefreshSessionRepository(Protocol): + """Persistence port for refresh-token session creation and rotation.""" + async def create( self, user_id: str, token_hash: str, expires_at: datetime - ) -> RefreshSession: ... + ) -> RefreshSession: + """Persist a hashed refresh-token session.""" + ... - async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: ... + async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: + """Return a non-expired active refresh session by token hash.""" + ... async def revoke( self, token_hash: str, now: datetime, replaced_by_hash: str | None = None - ) -> None: ... + ) -> None: + """Mark a refresh session as revoked, optionally linking its replacement.""" + ... class OtpStore(Protocol): - async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: ... + """Temporary OTP storage port with TTL, attempt, and rate-limit behavior.""" + + async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: + """Check and increment a named rate-limit bucket.""" + ... async def store_otp( self, @@ -36,14 +58,26 @@ class OtpStore(Protocol): otp_hash: str, ttl_seconds: int, max_attempts: int, - ) -> None: ... + ) -> None: + """Store a hashed OTP with TTL and attempt policy.""" + ... - async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: ... + async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: + """Verify an OTP hash and apply attempt/expiry behavior.""" + ... class SmsPublisher(Protocol): - async def publish(self, job: SmsJob) -> None: ... + """Queue publishing port used by auth use cases to request SMS delivery.""" + + async def publish(self, job: SmsJob) -> None: + """Publish an OTP SMS job to the messaging boundary.""" + ... class SmsClient(Protocol): - async def send_otp(self, mobile: str, code: str, template: str) -> None: ... + """Provider strategy port implemented by Kavenegar, SMS.ir, and debug SMS.""" + + async def send_otp(self, mobile: str, code: str, template: str) -> None: + """Send an OTP through a concrete SMS provider.""" + ... diff --git a/src/gapido_auth/infrastructure/container.py b/src/gapido_auth/infrastructure/container.py index 6c6f97b..77a227d 100644 --- a/src/gapido_auth/infrastructure/container.py +++ b/src/gapido_auth/infrastructure/container.py @@ -19,12 +19,16 @@ from gapido_auth.infrastructure.sms_provider import get_sms_template @dataclass(slots=True) class AppContainer: + """Runtime dependencies that need explicit shutdown after the gRPC server stops.""" + auth_service: AuthService mongo_client: AsyncIOMotorClient[Any] redis: Redis async def build_auth_service(settings: Settings, rabbitmq_channel: AbstractChannel) -> AppContainer: + """Create repositories, adapters, policies, and the AuthService use-case object.""" + mongo_client: AsyncIOMotorClient[Any] = AsyncIOMotorClient(settings.mongo_uri) db = mongo_client[settings.mongo_db_name] users = MongoUserRepository(db) diff --git a/src/gapido_auth/infrastructure/debug_sms_client.py b/src/gapido_auth/infrastructure/debug_sms_client.py index abbb79d..070d12b 100644 --- a/src/gapido_auth/infrastructure/debug_sms_client.py +++ b/src/gapido_auth/infrastructure/debug_sms_client.py @@ -7,20 +7,30 @@ logger = logging.getLogger(__name__) class DebugSmsStore(Protocol): - async def setex(self, name: str, time: int, value: str) -> object: ... + """Minimal Redis-like store used by the local debug SMS provider.""" + + async def setex(self, name: str, time: int, value: str) -> object: + """Store a value with a TTL using the Redis-compatible signature.""" + ... class DebugSmsClient(SmsClient): + """Local-only SMS strategy that stores the latest OTP for demo retrieval.""" + def __init__(self, store: DebugSmsStore, ttl_seconds: int) -> None: + """Configure the Redis-like store and short OTP debug retention.""" self._store = store self._ttl_seconds = ttl_seconds async def send_otp(self, mobile: str, code: str, template: str) -> None: + """Persist the OTP under a short-lived debug key instead of sending real SMS.""" + key = debug_sms_key(mobile) await self._store.setex(key, self._ttl_seconds, code) logger.info("Debug SMS stored for mobile=%s template=%s", mobile, template) def debug_sms_key(mobile: str) -> str: - return f"debug:sms:last:{mobile}" + """Return the Redis key used by the demo UI to read the latest local OTP.""" + return f"debug:sms:last:{mobile}" diff --git a/src/gapido_auth/infrastructure/kavenegar_client.py b/src/gapido_auth/infrastructure/kavenegar_client.py index 066f9b7..fafdbae 100644 --- a/src/gapido_auth/infrastructure/kavenegar_client.py +++ b/src/gapido_auth/infrastructure/kavenegar_client.py @@ -10,17 +10,22 @@ logger = logging.getLogger(__name__) class KavenegarSmsClient(SmsClient): + """Kavenegar verify/lookup implementation of the SMS provider strategy.""" + def __init__( self, api_key: str, timeout_seconds: float = 10.0, transport: httpx.AsyncBaseTransport | None = None, ) -> None: + """Configure credentials, timeout, and optional test transport.""" self._api_key = api_key self._timeout_seconds = timeout_seconds self._transport = transport async def send_otp(self, mobile: str, code: str, template: str) -> None: + """Send an OTP through Kavenegar and raise on transport or API failure.""" + url = f"https://api.kavenegar.com/v1/{self._api_key}/verify/lookup.json" payload = {"receptor": mobile, "template": template, "token": code, "type": "sms"} try: diff --git a/src/gapido_auth/infrastructure/mongo_repositories.py b/src/gapido_auth/infrastructure/mongo_repositories.py index 3e7b47a..b2f236a 100644 --- a/src/gapido_auth/infrastructure/mongo_repositories.py +++ b/src/gapido_auth/infrastructure/mongo_repositories.py @@ -10,10 +10,12 @@ 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"]), @@ -25,6 +27,7 @@ def _user_from_doc(doc: dict[str, Any]) -> User: 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"]), @@ -37,24 +40,35 @@ def _session_from_doc(doc: dict[str, Any]) -> RefreshSession: 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}, @@ -75,6 +89,8 @@ class MongoUserRepository(UserRepository): 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}, @@ -91,15 +107,22 @@ class MongoUserRepository(UserRepository): 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( { @@ -117,6 +140,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository): 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}} ) @@ -125,6 +150,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository): 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 @@ -132,4 +159,3 @@ class MongoRefreshSessionRepository(RefreshSessionRepository): {"token_hash": token_hash, "revoked_at": None}, {"$set": update}, ) - diff --git a/src/gapido_auth/infrastructure/rabbitmq.py b/src/gapido_auth/infrastructure/rabbitmq.py index eb36076..fcca452 100644 --- a/src/gapido_auth/infrastructure/rabbitmq.py +++ b/src/gapido_auth/infrastructure/rabbitmq.py @@ -16,10 +16,14 @@ SMS_DLX = "gapido.sms.dlx" async def connect_robust(url: str) -> AbstractRobustConnection: + """Open a reconnecting RabbitMQ connection for publishers and workers.""" + return await aio_pika.connect_robust(url) async def declare_sms_topology(channel: AbstractChannel) -> tuple[Any, AbstractQueue, Any]: + """Declare durable SMS exchange, queue, and dead-letter queue topology.""" + exchange = await channel.declare_exchange( SMS_EXCHANGE, aio_pika.ExchangeType.DIRECT, durable=True ) @@ -36,11 +40,16 @@ async def declare_sms_topology(channel: AbstractChannel) -> tuple[Any, AbstractQ class RabbitMqSmsPublisher(SmsPublisher): + """RabbitMQ publisher that sends durable OTP SMS jobs to the worker queue.""" + def __init__(self, channel: AbstractChannel) -> None: + """Store the channel used for SMS job publishing.""" self._channel = channel self._exchange: Any | None = None async def publish(self, job: SmsJob) -> None: + """Serialize and publish an OTP SMS job to the configured routing key.""" + if self._exchange is None: self._exchange, _, _ = await declare_sms_topology(self._channel) diff --git a/src/gapido_auth/infrastructure/redis_otp_store.py b/src/gapido_auth/infrastructure/redis_otp_store.py index 67378c7..b8ecf7c 100644 --- a/src/gapido_auth/infrastructure/redis_otp_store.py +++ b/src/gapido_auth/infrastructure/redis_otp_store.py @@ -7,10 +7,15 @@ from gapido_auth.domain.ports import OtpStore class RedisOtpStore(OtpStore): + """Redis adapter for OTP hashes, verification attempts, and request throttling.""" + def __init__(self, redis: Redis) -> None: + """Bind the store to an async Redis client.""" self._redis = redis async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: + """Increment a rate-limit counter and report whether it remains within limit.""" + count = await self._redis.incr(key) if count == 1: await self._redis.expire(key, window_seconds) @@ -24,6 +29,8 @@ class RedisOtpStore(OtpStore): ttl_seconds: int, max_attempts: int, ) -> None: + """Store a hashed OTP and reset its attempt counter with the same TTL window.""" + key = self._otp_key(mobile, purpose) attempts_key = self._attempts_key(mobile, purpose) async with self._redis.pipeline(transaction=True) as pipe: @@ -34,6 +41,8 @@ class RedisOtpStore(OtpStore): await pipe.execute() async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: + """Compare a submitted OTP hash and delete state on success or exhausted attempts.""" + key = self._otp_key(mobile, purpose) attempts_key = self._attempts_key(mobile, purpose) @@ -60,8 +69,10 @@ class RedisOtpStore(OtpStore): @staticmethod def _otp_key(mobile: str, purpose: str) -> str: + """Return the Redis hash key for an OTP challenge.""" return f"otp:{mobile}:{purpose}" @staticmethod def _attempts_key(mobile: str, purpose: str) -> str: + """Return the Redis counter key for OTP verification attempts.""" return f"otp-attempts:{mobile}:{purpose}" diff --git a/src/gapido_auth/infrastructure/sms_ir_client.py b/src/gapido_auth/infrastructure/sms_ir_client.py index 162e3a5..e709963 100644 --- a/src/gapido_auth/infrastructure/sms_ir_client.py +++ b/src/gapido_auth/infrastructure/sms_ir_client.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) class SmsIrSmsClient(SmsClient): + """SMS.ir verify API implementation of the SMS provider strategy.""" + _endpoint = "https://api.sms.ir/v1/send/verify" def __init__( @@ -18,11 +20,14 @@ class SmsIrSmsClient(SmsClient): timeout_seconds: float = 10.0, transport: httpx.AsyncBaseTransport | None = None, ) -> None: + """Configure credentials, timeout, and optional test transport.""" self._api_key = api_key self._timeout_seconds = timeout_seconds self._transport = transport async def send_otp(self, mobile: str, code: str, template: str) -> None: + """Send an OTP through SMS.ir using the configured verify template id.""" + headers = { "Content-Type": "application/json", "Accept": "application/json", @@ -53,4 +58,3 @@ class SmsIrSmsClient(SmsClient): raise ExternalServiceError("SMS.ir API error") logger.info("OTP SMS sent successfully to mobile=%s via SMS.ir", mobile) - diff --git a/src/gapido_auth/infrastructure/sms_provider.py b/src/gapido_auth/infrastructure/sms_provider.py index 589ca8d..866096e 100644 --- a/src/gapido_auth/infrastructure/sms_provider.py +++ b/src/gapido_auth/infrastructure/sms_provider.py @@ -12,6 +12,8 @@ def create_sms_client( transport: httpx.AsyncBaseTransport | None = None, debug_store: DebugSmsStore | None = None, ) -> SmsClient: + """Build the configured SMS provider strategy for the worker process.""" + match settings.sms_provider: case "kavenegar": return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport) @@ -24,6 +26,8 @@ def create_sms_client( def get_sms_template(settings: Settings) -> str: + """Return the provider-specific OTP template identifier used in queued SMS jobs.""" + match settings.sms_provider: case "kavenegar": return settings.kavenegar_login_template diff --git a/src/gapido_auth/infrastructure/worker.py b/src/gapido_auth/infrastructure/worker.py index cf90b3c..2d54e11 100644 --- a/src/gapido_auth/infrastructure/worker.py +++ b/src/gapido_auth/infrastructure/worker.py @@ -27,6 +27,8 @@ async def handle_message( exchange: Any, dlx: Any, ) -> None: + """Process one SMS job, retry bounded failures, and dead-letter permanent failures.""" + async with message.process(ignore_processed=True, requeue=False): payload = json.loads(message.body.decode()) job = SmsJob(**payload) @@ -48,6 +50,7 @@ async def handle_message( ) return + # Re-publish instead of requeueing indefinitely so retries remain bounded. logger.warning( "SMS job failed for mobile=%s retry=%s", job.mobile, retry_count + 1, exc_info=True ) @@ -63,6 +66,8 @@ async def handle_message( async def main() -> None: + """Start the RabbitMQ consumer and bind it to the configured SMS strategy.""" + logging.basicConfig(level=logging.INFO) settings = get_settings() connection = await connect_robust(settings.rabbitmq_url) diff --git a/src/gapido_auth/tools/generate_proto.py b/src/gapido_auth/tools/generate_proto.py index 61915a2..c192042 100644 --- a/src/gapido_auth/tools/generate_proto.py +++ b/src/gapido_auth/tools/generate_proto.py @@ -4,6 +4,7 @@ from grpc_tools import protoc def main() -> None: + """Regenerate Python gRPC stubs from the checked-in auth protobuf file.""" root = Path(__file__).resolve().parents[3] proto_root = root / "proto" src_root = root / "src" @@ -23,4 +24,3 @@ def main() -> None: if __name__ == "__main__": main() - diff --git a/src/gapido_auth/transport/grpc/auth_servicer.py b/src/gapido_auth/transport/grpc/auth_servicer.py index a844c62..310944c 100644 --- a/src/gapido_auth/transport/grpc/auth_servicer.py +++ b/src/gapido_auth/transport/grpc/auth_servicer.py @@ -23,10 +23,15 @@ logger = logging.getLogger(__name__) class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): + """gRPC transport adapter that maps protobuf calls to AuthService use cases.""" + def __init__(self, auth_service: AuthService) -> None: + """Bind the servicer to the application auth service.""" self._auth_service = auth_service async def RequestOtp(self, request, context): # type: ignore[no-untyped-def] + """Handle public OTP request calls.""" + try: await self._auth_service.request_otp( mobile=request.mobile, @@ -38,6 +43,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): await _abort_for_app_error(context, exc) async def VerifyOtp(self, request, context): # type: ignore[no-untyped-def] + """Handle public OTP verification and token issuance calls.""" + try: token_pair = await self._auth_service.verify_otp( mobile=request.mobile, @@ -49,6 +56,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): await _abort_for_app_error(context, exc) async def RefreshToken(self, request, context): # type: ignore[no-untyped-def] + """Handle refresh-token rotation calls.""" + try: token_pair = await self._auth_service.refresh_token(request.refresh_token) return _token_response(token_pair) @@ -56,6 +65,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): await _abort_for_app_error(context, exc) async def RevokeRefreshToken(self, request, context): # type: ignore[no-untyped-def] + """Handle authenticated refresh-token revocation calls.""" + try: token = _extract_bearer_token(context.invocation_metadata()) await self._auth_service.get_authenticated_user(token) @@ -65,9 +76,13 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): await _abort_for_app_error(context, exc) async def PublicPing(self, request, context): # type: ignore[no-untyped-def] + """Return a public response without authentication.""" + return auth_pb2.PingResponse(message="public ok") async def UserOnly(self, request, context): # type: ignore[no-untyped-def] + """Return a response for any active authenticated user.""" + try: user = await self._auth_service.get_authenticated_user( _extract_bearer_token(context.invocation_metadata()) @@ -77,6 +92,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): await _abort_for_app_error(context, exc) async def AdminOnly(self, request, context): # type: ignore[no-untyped-def] + """Return a response only for authenticated admin users.""" + try: user = await self._auth_service.require_role( _extract_bearer_token(context.invocation_metadata()), Role.ADMIN @@ -87,6 +104,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer): def _token_response(token_pair: TokenPair): # type: ignore[no-untyped-def] + """Convert an application token pair into a protobuf response.""" + return auth_pb2.TokenResponse( access_token=token_pair.access_token, refresh_token=token_pair.refresh_token, @@ -97,10 +116,14 @@ def _token_response(token_pair: TokenPair): # type: ignore[no-untyped-def] def _protected_response(user: User, message: str): # type: ignore[no-untyped-def] + """Convert an authenticated user into a protected-method protobuf response.""" + return auth_pb2.ProtectedResponse(user_id=user.id, role=user.role.value, message=message) def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str: + """Read bearer token metadata from a protected gRPC invocation.""" + for key, value in metadata: if key.lower() == "authorization" and value.startswith("Bearer "): return value.removeprefix("Bearer ").strip() @@ -108,6 +131,8 @@ def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str: async def _abort_for_app_error(context: grpc.aio.ServicerContext, exc: AppError) -> None: + """Translate expected application errors into meaningful gRPC status codes.""" + if isinstance(exc, AuthenticationError | InvalidOtp | OtpExpired | OtpAttemptsExceeded): await context.abort(grpc.StatusCode.UNAUTHENTICATED, str(exc)) if isinstance(exc, InactiveUser): diff --git a/src/gapido_auth/transport/grpc/server.py b/src/gapido_auth/transport/grpc/server.py index 26934ba..e165338 100644 --- a/src/gapido_auth/transport/grpc/server.py +++ b/src/gapido_auth/transport/grpc/server.py @@ -16,6 +16,7 @@ logger = logging.getLogger(__name__) async def serve() -> None: + """Start the async gRPC auth server and own infrastructure lifecycle.""" logging.basicConfig(level=logging.INFO) settings = get_settings() rabbitmq = await connect_robust(settings.rabbitmq_url) @@ -26,6 +27,7 @@ async def serve() -> None: auth_servicer = AuthGrpcServicer(container.auth_service) auth_pb2_grpc.add_AuthServiceServicer_to_server(auth_servicer, server) + # Health and reflection make the service easier to inspect with grpcurl. health_servicer = health.HealthServicer() health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server) service_names = ( diff --git a/src/gapido_demo/app.py b/src/gapido_demo/app.py index 572b426..adf2905 100644 --- a/src/gapido_demo/app.py +++ b/src/gapido_demo/app.py @@ -20,46 +20,73 @@ T = TypeVar("T") class AuthClient(Protocol): - async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: ... + """Minimal client contract used by FastAPI routes and tests.""" - async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: ... + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: + """Ask the auth service to create and dispatch an OTP.""" + ... - async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: ... + async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: + """Verify an OTP and return the token pair produced by auth-service.""" + ... + + async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: + """Rotate a refresh token through auth-service.""" + ... async def revoke_refresh_token( self, access_token: str, refresh_token: str - ) -> dict[str, bool]: ... + ) -> dict[str, bool]: + """Revoke a refresh session using bearer-token metadata.""" + ... - async def public_ping(self) -> dict[str, str]: ... + async def public_ping(self) -> dict[str, str]: + """Call the public demo RPC without authentication.""" + ... - async def user_only(self, access_token: str) -> dict[str, str]: ... + async def user_only(self, access_token: str) -> dict[str, str]: + """Call the user-protected demo RPC with an access token.""" + ... - async def admin_only(self, access_token: str) -> dict[str, str]: ... + async def admin_only(self, access_token: str) -> dict[str, str]: + """Call the admin-protected demo RPC with an access token.""" + ... class RequestOtpBody(BaseModel): + """HTTP body for starting the mobile OTP login flow.""" + mobile: str = Field(min_length=10, max_length=16) purpose: str = "login" class VerifyOtpBody(RequestOtpBody): + """HTTP body for verifying a received OTP code.""" + code: str = Field(min_length=6, max_length=6) class RefreshBody(BaseModel): + """HTTP body carrying the opaque refresh token.""" + refresh_token: str = Field(min_length=20) class TokenBody(BaseModel): + """HTTP body carrying a bearer access token for demo actions.""" + access_token: str = Field(min_length=20) class RevokeBody(TokenBody): + """HTTP body for revoking the current refresh session.""" + refresh_token: str = Field(min_length=20) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Create and close shared outbound clients for the demo service.""" settings = get_settings() app.state.settings = settings app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target) @@ -81,24 +108,29 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") def get_auth_client(request: Request) -> AuthClient: + """Resolve the configured auth-service client from application state.""" return cast(AuthClient, request.app.state.auth_client) def get_app_settings(request: Request) -> Settings: + """Resolve immutable runtime settings for request handlers.""" return cast(Settings, request.app.state.settings) def get_debug_redis(request: Request) -> Redis | None: + """Resolve the optional Redis client used only by local debug OTP mode.""" return cast(Redis | None, request.app.state.debug_redis) @app.get("/") async def index() -> FileResponse: + """Serve the single-page browser demo.""" return FileResponse(STATIC_DIR / "index.html") @app.get("/healthz") async def healthz() -> dict[str, str]: + """Return a lightweight readiness response for Compose and Caddy checks.""" return {"status": "ok"} @@ -107,6 +139,7 @@ async def request_otp( body: RequestOtpBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Forward an OTP request from the browser to the gRPC auth service.""" return cast( dict[str, object], await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)), @@ -118,6 +151,7 @@ async def verify_otp( body: VerifyOtpBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Verify an OTP and return token data to the browser demo.""" response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose)) return cast(dict[str, object], asdict(response)) @@ -127,6 +161,7 @@ async def refresh_token( body: RefreshBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Rotate a refresh token and return a new token pair.""" response = await _call_grpc(lambda: client.refresh_token(body.refresh_token)) return cast(dict[str, object], asdict(response)) @@ -136,6 +171,7 @@ async def revoke_refresh_token( body: RevokeBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Revoke the current refresh session through auth-service.""" return cast( dict[str, object], await _call_grpc( @@ -146,6 +182,7 @@ async def revoke_refresh_token( @app.post("/api/demo/public") async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]: + """Call the public gRPC endpoint to prove unauthenticated access.""" return cast(dict[str, object], await _call_grpc(client.public_ping)) @@ -154,6 +191,7 @@ async def user_demo( body: TokenBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Call the user-protected gRPC endpoint with bearer metadata.""" return cast(dict[str, object], await _call_grpc(lambda: client.user_only(body.access_token))) @@ -162,6 +200,7 @@ async def admin_demo( body: TokenBody, client: Annotated[AuthClient, Depends(get_auth_client)], ) -> dict[str, object]: + """Call the admin-protected gRPC endpoint with bearer metadata.""" return cast(dict[str, object], await _call_grpc(lambda: client.admin_only(body.access_token))) @@ -171,6 +210,7 @@ async def debug_otp( settings: Annotated[Settings, Depends(get_app_settings)], redis: Annotated[Redis | None, Depends(get_debug_redis)], ) -> dict[str, str | None]: + """Expose the last debug OTP in local demo mode only.""" if not settings.demo_enable_debug_otp or redis is None: raise HTTPException(status_code=404, detail="debug OTP endpoint is disabled") code = await redis.get(debug_sms_key(mobile)) @@ -178,6 +218,7 @@ async def debug_otp( async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T: + """Execute a gRPC call and translate transport errors to HTTP errors.""" try: return await call() except grpc.aio.AioRpcError as exc: @@ -187,6 +228,7 @@ async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T: def _grpc_to_http_status(code: grpc.StatusCode) -> int: + """Map auth-service gRPC status codes to browser-friendly HTTP statuses.""" match code: case grpc.StatusCode.INVALID_ARGUMENT: return 400 diff --git a/src/gapido_demo/grpc_client.py b/src/gapido_demo/grpc_client.py index b6af1b8..a84b8bb 100644 --- a/src/gapido_demo/grpc_client.py +++ b/src/gapido_demo/grpc_client.py @@ -9,6 +9,8 @@ from gapido_auth.generated import auth_pb2, auth_pb2_grpc @dataclass(frozen=True, slots=True) class DemoTokenResponse: + """Token payload shape returned by the demo BFF to the browser.""" + access_token: str refresh_token: str token_type: str @@ -17,32 +19,40 @@ class DemoTokenResponse: class AuthGrpcClient: + """Thin async gRPC client used by the FastAPI demo service.""" + def __init__(self, target: str) -> None: + """Open an async channel to auth-service.""" self._channel = grpc.aio.insecure_channel(target) self._stub = auth_pb2_grpc.AuthServiceStub(self._channel) async def close(self) -> None: + """Close the underlying gRPC channel during FastAPI shutdown.""" await self._channel.close() async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: + """Forward an OTP request and normalize the protobuf response.""" response = await self._stub.RequestOtp( auth_pb2.RequestOtpRequest(mobile=mobile, purpose=purpose) ) return {"accepted": bool(response.accepted)} async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: + """Verify an OTP and convert the protobuf token response.""" response = await self._stub.VerifyOtp( auth_pb2.VerifyOtpRequest(mobile=mobile, code=code, purpose=purpose) ) return _token_response(response) async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: + """Refresh and rotate an opaque refresh token.""" response = await self._stub.RefreshToken( auth_pb2.RefreshTokenRequest(refresh_token=refresh_token) ) return _token_response(response) async def revoke_refresh_token(self, access_token: str, refresh_token: str) -> dict[str, bool]: + """Revoke a refresh token using access-token authorization metadata.""" response = await self._stub.RevokeRefreshToken( auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token), metadata=_auth_metadata(access_token), @@ -50,16 +60,19 @@ class AuthGrpcClient: return {"revoked": bool(response.revoked)} async def public_ping(self) -> dict[str, str]: + """Call the public demonstration endpoint.""" response = await self._stub.PublicPing(auth_pb2.PingRequest()) return {"message": str(response.message)} async def user_only(self, access_token: str) -> dict[str, str]: + """Call the user-only demonstration endpoint.""" response = await self._stub.UserOnly( auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token) ) return _protected_response(response) async def admin_only(self, access_token: str) -> dict[str, str]: + """Call the admin-only demonstration endpoint.""" response = await self._stub.AdminOnly( auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token) ) @@ -67,10 +80,12 @@ class AuthGrpcClient: def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]: + """Build gRPC bearer metadata expected by auth-service.""" return (("authorization", f"Bearer {access_token}"),) def _token_response(response: Any) -> DemoTokenResponse: + """Convert a protobuf token message into a dataclass.""" return DemoTokenResponse( access_token=str(response.access_token), refresh_token=str(response.refresh_token), @@ -81,6 +96,7 @@ def _token_response(response: Any) -> DemoTokenResponse: def _protected_response(response: Any) -> dict[str, str]: + """Convert a protected protobuf response into a JSON-ready dict.""" return { "user_id": str(response.user_id), "role": str(response.role),