docs(code): add concise docstrings
This commit is contained in:
@@ -28,6 +28,8 @@ from gapido_auth.domain.ports import (
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AuthConfig:
|
class AuthConfig:
|
||||||
|
"""Runtime policy values used by auth use cases."""
|
||||||
|
|
||||||
otp_secret: str
|
otp_secret: str
|
||||||
otp_ttl_seconds: int
|
otp_ttl_seconds: int
|
||||||
otp_max_attempts: int
|
otp_max_attempts: int
|
||||||
@@ -38,6 +40,8 @@ class AuthConfig:
|
|||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
|
"""Application service coordinating OTP login, token rotation, and RBAC."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
users: UserRepository,
|
users: UserRepository,
|
||||||
@@ -47,6 +51,7 @@ class AuthService:
|
|||||||
token_codec: JwtTokenCodec,
|
token_codec: JwtTokenCodec,
|
||||||
config: AuthConfig,
|
config: AuthConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Wire repository, queue, OTP, and token ports for use-case execution."""
|
||||||
self._users = users
|
self._users = users
|
||||||
self._refresh_sessions = refresh_sessions
|
self._refresh_sessions = refresh_sessions
|
||||||
self._otp_store = otp_store
|
self._otp_store = otp_store
|
||||||
@@ -55,6 +60,8 @@ class AuthService:
|
|||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
async def request_otp(self, mobile: str, purpose: str, client_key: str) -> None:
|
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_mobile(mobile)
|
||||||
_validate_purpose(purpose)
|
_validate_purpose(purpose)
|
||||||
mobile_key = f"otp-request:mobile:{mobile}:{purpose}"
|
mobile_key = f"otp-request:mobile:{mobile}:{purpose}"
|
||||||
@@ -73,6 +80,7 @@ class AuthService:
|
|||||||
raise RateLimitExceeded("too many OTP requests")
|
raise RateLimitExceeded("too many OTP requests")
|
||||||
|
|
||||||
code = generate_otp_code()
|
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)
|
otp_hash = hash_otp(self._config.otp_secret, mobile, purpose, code)
|
||||||
await self._otp_store.store_otp(
|
await self._otp_store.store_otp(
|
||||||
mobile=mobile,
|
mobile=mobile,
|
||||||
@@ -86,6 +94,8 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> TokenPair:
|
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_mobile(mobile)
|
||||||
_validate_purpose(purpose)
|
_validate_purpose(purpose)
|
||||||
if not code.isdigit() or len(code) != 6:
|
if not code.isdigit() or len(code) != 6:
|
||||||
@@ -100,6 +110,8 @@ class AuthService:
|
|||||||
return await self._issue_token_pair(user)
|
return await self._issue_token_pair(user)
|
||||||
|
|
||||||
async def refresh_token(self, refresh_token: str) -> TokenPair:
|
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)
|
token_hash = hash_refresh_token(refresh_token)
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
session = await self._refresh_sessions.get_active_by_hash(token_hash, 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:
|
if user is None or not user.is_active:
|
||||||
raise AuthenticationError("invalid refresh token")
|
raise AuthenticationError("invalid refresh token")
|
||||||
|
|
||||||
|
# Rotation revokes the old token hash and persists a fresh session hash.
|
||||||
new_refresh_token = generate_refresh_token()
|
new_refresh_token = generate_refresh_token()
|
||||||
new_hash = hash_refresh_token(new_refresh_token)
|
new_hash = hash_refresh_token(new_refresh_token)
|
||||||
expires_at = now + timedelta(seconds=self._config.refresh_token_ttl_seconds)
|
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:
|
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())
|
await self._refresh_sessions.revoke(hash_refresh_token(refresh_token), utc_now())
|
||||||
|
|
||||||
async def get_authenticated_user(self, access_token: str) -> User:
|
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)
|
claims = self._token_codec.decode_access_token(access_token)
|
||||||
user = await self._users.get_by_id(claims.user_id)
|
user = await self._users.get_by_id(claims.user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
@@ -137,12 +154,16 @@ class AuthService:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
async def require_role(self, access_token: str, role: Role) -> 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)
|
user = await self.get_authenticated_user(access_token)
|
||||||
if user.role != role:
|
if user.role != role:
|
||||||
raise PermissionDenied("insufficient permissions")
|
raise PermissionDenied("insufficient permissions")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def _issue_token_pair(self, user: User) -> TokenPair:
|
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:
|
if not user.is_active:
|
||||||
raise InactiveUser("user is inactive")
|
raise InactiveUser("user is inactive")
|
||||||
|
|
||||||
@@ -160,11 +181,15 @@ class AuthService:
|
|||||||
|
|
||||||
|
|
||||||
def _validate_mobile(mobile: str) -> None:
|
def _validate_mobile(mobile: str) -> None:
|
||||||
|
"""Validate the E.164-like mobile format accepted by the challenge service."""
|
||||||
|
|
||||||
normalized = mobile.removeprefix("+")
|
normalized = mobile.removeprefix("+")
|
||||||
if not normalized.isdigit() or len(normalized) < 10 or len(normalized) > 15:
|
if not normalized.isdigit() or len(normalized) < 10 or len(normalized) > 15:
|
||||||
raise ValidationError("mobile must be an E.164-like phone number")
|
raise ValidationError("mobile must be an E.164-like phone number")
|
||||||
|
|
||||||
|
|
||||||
def _validate_purpose(purpose: str) -> None:
|
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():
|
if not purpose or not purpose.replace("-", "").replace("_", "").isalnum():
|
||||||
raise ValidationError("purpose is invalid")
|
raise ValidationError("purpose is invalid")
|
||||||
|
|||||||
@@ -11,37 +11,54 @@ from gapido_auth.domain.errors import AuthenticationError
|
|||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
def utc_now() -> datetime:
|
||||||
|
"""Return timezone-aware UTC time for token/session timestamps."""
|
||||||
|
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def generate_otp_code() -> str:
|
def generate_otp_code() -> str:
|
||||||
|
"""Generate a cryptographically random six-digit OTP string."""
|
||||||
|
|
||||||
return f"{secrets.randbelow(1_000_000):06d}"
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
def hash_otp(secret: str, mobile: str, purpose: str, code: str) -> str:
|
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()
|
message = f"{mobile}:{purpose}:{code}".encode()
|
||||||
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
|
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def generate_refresh_token() -> str:
|
def generate_refresh_token() -> str:
|
||||||
|
"""Generate an opaque refresh token suitable for returning to clients."""
|
||||||
|
|
||||||
return secrets.token_urlsafe(48)
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
def hash_refresh_token(token: str) -> str:
|
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()
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class JwtTokenCodec:
|
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:
|
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._secret_key = secret_key
|
||||||
self._issuer = issuer
|
self._issuer = issuer
|
||||||
self._access_ttl_seconds = access_ttl_seconds
|
self._access_ttl_seconds = access_ttl_seconds
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def access_ttl_seconds(self) -> int:
|
def access_ttl_seconds(self) -> int:
|
||||||
|
"""Return the configured access-token TTL exposed to clients."""
|
||||||
|
|
||||||
return self._access_ttl_seconds
|
return self._access_ttl_seconds
|
||||||
|
|
||||||
def encode_access_token(self, user_id: str, role: Role) -> str:
|
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()
|
now = utc_now()
|
||||||
expires_at = now + timedelta(seconds=self._access_ttl_seconds)
|
expires_at = now + timedelta(seconds=self._access_ttl_seconds)
|
||||||
payload = {
|
payload = {
|
||||||
@@ -56,6 +73,8 @@ class JwtTokenCodec:
|
|||||||
return jwt.encode(payload, self._secret_key, algorithm="HS256")
|
return jwt.encode(payload, self._secret_key, algorithm="HS256")
|
||||||
|
|
||||||
def decode_access_token(self, token: str) -> AccessClaims:
|
def decode_access_token(self, token: str) -> AccessClaims:
|
||||||
|
"""Validate an access JWT and return typed claims used by RBAC checks."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
@@ -80,4 +99,3 @@ class JwtTokenCodec:
|
|||||||
role=role,
|
role=role,
|
||||||
expires_at=datetime.fromtimestamp(int(payload["exp"]), UTC),
|
expires_at=datetime.fromtimestamp(int(payload["exp"]), UTC),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
"""Environment-driven settings shared by auth, worker, and demo services."""
|
||||||
|
|
||||||
app_env: str = "local"
|
app_env: str = "local"
|
||||||
|
|
||||||
mongo_uri: str = "mongodb://localhost:27017"
|
mongo_uri: str = "mongodb://localhost:27017"
|
||||||
@@ -46,4 +48,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
"""Return cached process settings loaded from environment and optional .env files."""
|
||||||
|
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ from enum import StrEnum
|
|||||||
|
|
||||||
|
|
||||||
class Role(StrEnum):
|
class Role(StrEnum):
|
||||||
|
"""User roles used by the auth service for access-control checks."""
|
||||||
|
|
||||||
ADMIN = "admin"
|
ADMIN = "admin"
|
||||||
USER = "user"
|
USER = "user"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class User:
|
class User:
|
||||||
|
"""Persisted account identity created after a successful OTP verification."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
mobile: str
|
mobile: str
|
||||||
role: Role
|
role: Role
|
||||||
@@ -20,6 +24,8 @@ class User:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RefreshSession:
|
class RefreshSession:
|
||||||
|
"""Persisted refresh-token session stored as a token hash, never plaintext."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
token_hash: str
|
token_hash: str
|
||||||
@@ -31,6 +37,8 @@ class RefreshSession:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SmsJob:
|
class SmsJob:
|
||||||
|
"""RabbitMQ payload for delivering an OTP through the configured SMS provider."""
|
||||||
|
|
||||||
mobile: str
|
mobile: str
|
||||||
code: str
|
code: str
|
||||||
template: str
|
template: str
|
||||||
@@ -39,6 +47,8 @@ class SmsJob:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TokenPair:
|
class TokenPair:
|
||||||
|
"""Access and refresh tokens returned to a client after login or refresh."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -48,7 +58,8 @@ class TokenPair:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class AccessClaims:
|
class AccessClaims:
|
||||||
|
"""Trusted claims extracted from a validated access JWT."""
|
||||||
|
|
||||||
user_id: str
|
user_id: str
|
||||||
role: Role
|
role: Role
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -3,36 +3,54 @@ class AppError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class RateLimitExceeded(AppError):
|
class RateLimitExceeded(AppError):
|
||||||
|
"""Raised when OTP request or verification limits are exceeded."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(AppError):
|
class ValidationError(AppError):
|
||||||
|
"""Raised when client input cannot be accepted by the application layer."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidOtp(AppError):
|
class InvalidOtp(AppError):
|
||||||
|
"""Raised when an OTP exists but the submitted code does not match."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OtpExpired(AppError):
|
class OtpExpired(AppError):
|
||||||
|
"""Raised when an OTP is missing because it expired or was never requested."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OtpAttemptsExceeded(AppError):
|
class OtpAttemptsExceeded(AppError):
|
||||||
|
"""Raised when OTP verification attempts exceed the configured limit."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationError(AppError):
|
class AuthenticationError(AppError):
|
||||||
|
"""Raised when credentials or tokens cannot authenticate a caller."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(AppError):
|
class PermissionDenied(AppError):
|
||||||
|
"""Raised when an authenticated caller lacks the required role."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InactiveUser(AppError):
|
class InactiveUser(AppError):
|
||||||
|
"""Raised when an existing user is disabled and cannot authenticate."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExternalServiceError(AppError):
|
class ExternalServiceError(AppError):
|
||||||
|
"""Raised when an external provider fails or returns an error response."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -5,29 +5,51 @@ from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User
|
|||||||
|
|
||||||
|
|
||||||
class UserRepository(Protocol):
|
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):
|
class RefreshSessionRepository(Protocol):
|
||||||
|
"""Persistence port for refresh-token session creation and rotation."""
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self, user_id: str, token_hash: str, expires_at: datetime
|
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(
|
async def revoke(
|
||||||
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
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):
|
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(
|
async def store_otp(
|
||||||
self,
|
self,
|
||||||
@@ -36,14 +58,26 @@ class OtpStore(Protocol):
|
|||||||
otp_hash: str,
|
otp_hash: str,
|
||||||
ttl_seconds: int,
|
ttl_seconds: int,
|
||||||
max_attempts: 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):
|
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):
|
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."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ from gapido_auth.infrastructure.sms_provider import get_sms_template
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class AppContainer:
|
class AppContainer:
|
||||||
|
"""Runtime dependencies that need explicit shutdown after the gRPC server stops."""
|
||||||
|
|
||||||
auth_service: AuthService
|
auth_service: AuthService
|
||||||
mongo_client: AsyncIOMotorClient[Any]
|
mongo_client: AsyncIOMotorClient[Any]
|
||||||
redis: Redis
|
redis: Redis
|
||||||
|
|
||||||
|
|
||||||
async def build_auth_service(settings: Settings, rabbitmq_channel: AbstractChannel) -> AppContainer:
|
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)
|
mongo_client: AsyncIOMotorClient[Any] = AsyncIOMotorClient(settings.mongo_uri)
|
||||||
db = mongo_client[settings.mongo_db_name]
|
db = mongo_client[settings.mongo_db_name]
|
||||||
users = MongoUserRepository(db)
|
users = MongoUserRepository(db)
|
||||||
|
|||||||
@@ -7,20 +7,30 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class DebugSmsStore(Protocol):
|
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):
|
class DebugSmsClient(SmsClient):
|
||||||
|
"""Local-only SMS strategy that stores the latest OTP for demo retrieval."""
|
||||||
|
|
||||||
def __init__(self, store: DebugSmsStore, ttl_seconds: int) -> None:
|
def __init__(self, store: DebugSmsStore, ttl_seconds: int) -> None:
|
||||||
|
"""Configure the Redis-like store and short OTP debug retention."""
|
||||||
self._store = store
|
self._store = store
|
||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
|
|
||||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
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)
|
key = debug_sms_key(mobile)
|
||||||
await self._store.setex(key, self._ttl_seconds, code)
|
await self._store.setex(key, self._ttl_seconds, code)
|
||||||
logger.info("Debug SMS stored for mobile=%s template=%s", mobile, template)
|
logger.info("Debug SMS stored for mobile=%s template=%s", mobile, template)
|
||||||
|
|
||||||
|
|
||||||
def debug_sms_key(mobile: str) -> str:
|
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}"
|
||||||
|
|||||||
@@ -10,17 +10,22 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class KavenegarSmsClient(SmsClient):
|
class KavenegarSmsClient(SmsClient):
|
||||||
|
"""Kavenegar verify/lookup implementation of the SMS provider strategy."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
timeout_seconds: float = 10.0,
|
timeout_seconds: float = 10.0,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Configure credentials, timeout, and optional test transport."""
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._timeout_seconds = timeout_seconds
|
self._timeout_seconds = timeout_seconds
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
|
||||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
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"
|
url = f"https://api.kavenegar.com/v1/{self._api_key}/verify/lookup.json"
|
||||||
payload = {"receptor": mobile, "template": template, "token": code, "type": "sms"}
|
payload = {"receptor": mobile, "template": template, "token": code, "type": "sms"}
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ from gapido_auth.domain.ports import RefreshSessionRepository, UserRepository
|
|||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
|
"""Return timezone-aware UTC now for Mongo document timestamps."""
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def _user_from_doc(doc: dict[str, Any]) -> User:
|
def _user_from_doc(doc: dict[str, Any]) -> User:
|
||||||
|
"""Map a Mongo user document into the domain entity."""
|
||||||
return User(
|
return User(
|
||||||
id=str(doc["_id"]),
|
id=str(doc["_id"]),
|
||||||
mobile=str(doc["mobile"]),
|
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:
|
def _session_from_doc(doc: dict[str, Any]) -> RefreshSession:
|
||||||
|
"""Map a Mongo refresh-session document into the domain entity."""
|
||||||
return RefreshSession(
|
return RefreshSession(
|
||||||
id=str(doc["_id"]),
|
id=str(doc["_id"]),
|
||||||
user_id=str(doc["user_id"]),
|
user_id=str(doc["user_id"]),
|
||||||
@@ -37,24 +40,35 @@ def _session_from_doc(doc: dict[str, Any]) -> RefreshSession:
|
|||||||
|
|
||||||
|
|
||||||
class MongoUserRepository(UserRepository):
|
class MongoUserRepository(UserRepository):
|
||||||
|
"""MongoDB adapter for user documents and admin bootstrap."""
|
||||||
|
|
||||||
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
||||||
|
"""Bind the repository to the users collection."""
|
||||||
self._collection = db.users
|
self._collection = db.users
|
||||||
|
|
||||||
async def create_indexes(self) -> None:
|
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([("mobile", ASCENDING)], unique=True)
|
||||||
await self._collection.create_index([("role", ASCENDING)])
|
await self._collection.create_index([("role", ASCENDING)])
|
||||||
|
|
||||||
async def get_by_id(self, user_id: str) -> User | None:
|
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):
|
if not ObjectId.is_valid(user_id):
|
||||||
return None
|
return None
|
||||||
doc = await self._collection.find_one({"_id": ObjectId(user_id)})
|
doc = await self._collection.find_one({"_id": ObjectId(user_id)})
|
||||||
return _user_from_doc(doc) if doc else None
|
return _user_from_doc(doc) if doc else None
|
||||||
|
|
||||||
async def get_by_mobile(self, mobile: str) -> User | 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})
|
doc = await self._collection.find_one({"mobile": mobile})
|
||||||
return _user_from_doc(doc) if doc else None
|
return _user_from_doc(doc) if doc else None
|
||||||
|
|
||||||
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User:
|
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()
|
now = _now()
|
||||||
await self._collection.update_one(
|
await self._collection.update_one(
|
||||||
{"mobile": mobile},
|
{"mobile": mobile},
|
||||||
@@ -75,6 +89,8 @@ class MongoUserRepository(UserRepository):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
async def ensure_admin_user(self, mobile: str) -> User:
|
async def ensure_admin_user(self, mobile: str) -> User:
|
||||||
|
"""Idempotently seed or promote the configured admin mobile."""
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
await self._collection.update_one(
|
await self._collection.update_one(
|
||||||
{"mobile": mobile},
|
{"mobile": mobile},
|
||||||
@@ -91,15 +107,22 @@ class MongoUserRepository(UserRepository):
|
|||||||
|
|
||||||
|
|
||||||
class MongoRefreshSessionRepository(RefreshSessionRepository):
|
class MongoRefreshSessionRepository(RefreshSessionRepository):
|
||||||
|
"""MongoDB adapter for hashed refresh-token sessions."""
|
||||||
|
|
||||||
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
||||||
|
"""Bind the repository to the refresh_sessions collection."""
|
||||||
self._collection = db.refresh_sessions
|
self._collection = db.refresh_sessions
|
||||||
|
|
||||||
async def create_indexes(self) -> None:
|
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([("token_hash", ASCENDING)], unique=True)
|
||||||
await self._collection.create_index([("user_id", ASCENDING), ("expires_at", ASCENDING)])
|
await self._collection.create_index([("user_id", ASCENDING), ("expires_at", ASCENDING)])
|
||||||
await self._collection.create_index([("revoked_at", ASCENDING)])
|
await self._collection.create_index([("revoked_at", ASCENDING)])
|
||||||
|
|
||||||
async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession:
|
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()
|
now = _now()
|
||||||
result = await self._collection.insert_one(
|
result = await self._collection.insert_one(
|
||||||
{
|
{
|
||||||
@@ -117,6 +140,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
|||||||
return _session_from_doc(doc)
|
return _session_from_doc(doc)
|
||||||
|
|
||||||
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-revoked, non-expired session by token hash."""
|
||||||
|
|
||||||
doc = await self._collection.find_one(
|
doc = await self._collection.find_one(
|
||||||
{"token_hash": token_hash, "revoked_at": None, "expires_at": {"$gt": now}}
|
{"token_hash": token_hash, "revoked_at": None, "expires_at": {"$gt": now}}
|
||||||
)
|
)
|
||||||
@@ -125,6 +150,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
|||||||
async def revoke(
|
async def revoke(
|
||||||
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Mark a refresh session revoked, optionally linking its replacement hash."""
|
||||||
|
|
||||||
update: dict[str, Any] = {"revoked_at": now}
|
update: dict[str, Any] = {"revoked_at": now}
|
||||||
if replaced_by_hash is not None:
|
if replaced_by_hash is not None:
|
||||||
update["replaced_by_hash"] = replaced_by_hash
|
update["replaced_by_hash"] = replaced_by_hash
|
||||||
@@ -132,4 +159,3 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
|||||||
{"token_hash": token_hash, "revoked_at": None},
|
{"token_hash": token_hash, "revoked_at": None},
|
||||||
{"$set": update},
|
{"$set": update},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ SMS_DLX = "gapido.sms.dlx"
|
|||||||
|
|
||||||
|
|
||||||
async def connect_robust(url: str) -> AbstractRobustConnection:
|
async def connect_robust(url: str) -> AbstractRobustConnection:
|
||||||
|
"""Open a reconnecting RabbitMQ connection for publishers and workers."""
|
||||||
|
|
||||||
return await aio_pika.connect_robust(url)
|
return await aio_pika.connect_robust(url)
|
||||||
|
|
||||||
|
|
||||||
async def declare_sms_topology(channel: AbstractChannel) -> tuple[Any, AbstractQueue, Any]:
|
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(
|
exchange = await channel.declare_exchange(
|
||||||
SMS_EXCHANGE, aio_pika.ExchangeType.DIRECT, durable=True
|
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):
|
class RabbitMqSmsPublisher(SmsPublisher):
|
||||||
|
"""RabbitMQ publisher that sends durable OTP SMS jobs to the worker queue."""
|
||||||
|
|
||||||
def __init__(self, channel: AbstractChannel) -> None:
|
def __init__(self, channel: AbstractChannel) -> None:
|
||||||
|
"""Store the channel used for SMS job publishing."""
|
||||||
self._channel = channel
|
self._channel = channel
|
||||||
self._exchange: Any | None = None
|
self._exchange: Any | None = None
|
||||||
|
|
||||||
async def publish(self, job: SmsJob) -> 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:
|
if self._exchange is None:
|
||||||
self._exchange, _, _ = await declare_sms_topology(self._channel)
|
self._exchange, _, _ = await declare_sms_topology(self._channel)
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ from gapido_auth.domain.ports import OtpStore
|
|||||||
|
|
||||||
|
|
||||||
class RedisOtpStore(OtpStore):
|
class RedisOtpStore(OtpStore):
|
||||||
|
"""Redis adapter for OTP hashes, verification attempts, and request throttling."""
|
||||||
|
|
||||||
def __init__(self, redis: Redis) -> None:
|
def __init__(self, redis: Redis) -> None:
|
||||||
|
"""Bind the store to an async Redis client."""
|
||||||
self._redis = redis
|
self._redis = redis
|
||||||
|
|
||||||
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool:
|
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)
|
count = await self._redis.incr(key)
|
||||||
if count == 1:
|
if count == 1:
|
||||||
await self._redis.expire(key, window_seconds)
|
await self._redis.expire(key, window_seconds)
|
||||||
@@ -24,6 +29,8 @@ class RedisOtpStore(OtpStore):
|
|||||||
ttl_seconds: int,
|
ttl_seconds: int,
|
||||||
max_attempts: int,
|
max_attempts: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Store a hashed OTP and reset its attempt counter with the same TTL window."""
|
||||||
|
|
||||||
key = self._otp_key(mobile, purpose)
|
key = self._otp_key(mobile, purpose)
|
||||||
attempts_key = self._attempts_key(mobile, purpose)
|
attempts_key = self._attempts_key(mobile, purpose)
|
||||||
async with self._redis.pipeline(transaction=True) as pipe:
|
async with self._redis.pipeline(transaction=True) as pipe:
|
||||||
@@ -34,6 +41,8 @@ class RedisOtpStore(OtpStore):
|
|||||||
await pipe.execute()
|
await pipe.execute()
|
||||||
|
|
||||||
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:
|
||||||
|
"""Compare a submitted OTP hash and delete state on success or exhausted attempts."""
|
||||||
|
|
||||||
key = self._otp_key(mobile, purpose)
|
key = self._otp_key(mobile, purpose)
|
||||||
attempts_key = self._attempts_key(mobile, purpose)
|
attempts_key = self._attempts_key(mobile, purpose)
|
||||||
|
|
||||||
@@ -60,8 +69,10 @@ class RedisOtpStore(OtpStore):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _otp_key(mobile: str, purpose: str) -> str:
|
def _otp_key(mobile: str, purpose: str) -> str:
|
||||||
|
"""Return the Redis hash key for an OTP challenge."""
|
||||||
return f"otp:{mobile}:{purpose}"
|
return f"otp:{mobile}:{purpose}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _attempts_key(mobile: str, purpose: str) -> str:
|
def _attempts_key(mobile: str, purpose: str) -> str:
|
||||||
|
"""Return the Redis counter key for OTP verification attempts."""
|
||||||
return f"otp-attempts:{mobile}:{purpose}"
|
return f"otp-attempts:{mobile}:{purpose}"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SmsIrSmsClient(SmsClient):
|
class SmsIrSmsClient(SmsClient):
|
||||||
|
"""SMS.ir verify API implementation of the SMS provider strategy."""
|
||||||
|
|
||||||
_endpoint = "https://api.sms.ir/v1/send/verify"
|
_endpoint = "https://api.sms.ir/v1/send/verify"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -18,11 +20,14 @@ class SmsIrSmsClient(SmsClient):
|
|||||||
timeout_seconds: float = 10.0,
|
timeout_seconds: float = 10.0,
|
||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Configure credentials, timeout, and optional test transport."""
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._timeout_seconds = timeout_seconds
|
self._timeout_seconds = timeout_seconds
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
|
|
||||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
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 = {
|
headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
@@ -53,4 +58,3 @@ class SmsIrSmsClient(SmsClient):
|
|||||||
raise ExternalServiceError("SMS.ir API error")
|
raise ExternalServiceError("SMS.ir API error")
|
||||||
|
|
||||||
logger.info("OTP SMS sent successfully to mobile=%s via SMS.ir", mobile)
|
logger.info("OTP SMS sent successfully to mobile=%s via SMS.ir", mobile)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ def create_sms_client(
|
|||||||
transport: httpx.AsyncBaseTransport | None = None,
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
debug_store: DebugSmsStore | None = None,
|
debug_store: DebugSmsStore | None = None,
|
||||||
) -> SmsClient:
|
) -> SmsClient:
|
||||||
|
"""Build the configured SMS provider strategy for the worker process."""
|
||||||
|
|
||||||
match settings.sms_provider:
|
match settings.sms_provider:
|
||||||
case "kavenegar":
|
case "kavenegar":
|
||||||
return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport)
|
return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport)
|
||||||
@@ -24,6 +26,8 @@ def create_sms_client(
|
|||||||
|
|
||||||
|
|
||||||
def get_sms_template(settings: Settings) -> str:
|
def get_sms_template(settings: Settings) -> str:
|
||||||
|
"""Return the provider-specific OTP template identifier used in queued SMS jobs."""
|
||||||
|
|
||||||
match settings.sms_provider:
|
match settings.sms_provider:
|
||||||
case "kavenegar":
|
case "kavenegar":
|
||||||
return settings.kavenegar_login_template
|
return settings.kavenegar_login_template
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ async def handle_message(
|
|||||||
exchange: Any,
|
exchange: Any,
|
||||||
dlx: Any,
|
dlx: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Process one SMS job, retry bounded failures, and dead-letter permanent failures."""
|
||||||
|
|
||||||
async with message.process(ignore_processed=True, requeue=False):
|
async with message.process(ignore_processed=True, requeue=False):
|
||||||
payload = json.loads(message.body.decode())
|
payload = json.loads(message.body.decode())
|
||||||
job = SmsJob(**payload)
|
job = SmsJob(**payload)
|
||||||
@@ -48,6 +50,7 @@ async def handle_message(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Re-publish instead of requeueing indefinitely so retries remain bounded.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SMS job failed for mobile=%s retry=%s", job.mobile, retry_count + 1, exc_info=True
|
"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:
|
async def main() -> None:
|
||||||
|
"""Start the RabbitMQ consumer and bind it to the configured SMS strategy."""
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
connection = await connect_robust(settings.rabbitmq_url)
|
connection = await connect_robust(settings.rabbitmq_url)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from grpc_tools import protoc
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Regenerate Python gRPC stubs from the checked-in auth protobuf file."""
|
||||||
root = Path(__file__).resolve().parents[3]
|
root = Path(__file__).resolve().parents[3]
|
||||||
proto_root = root / "proto"
|
proto_root = root / "proto"
|
||||||
src_root = root / "src"
|
src_root = root / "src"
|
||||||
@@ -23,4 +24,3 @@ def main() -> None:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||||
|
"""gRPC transport adapter that maps protobuf calls to AuthService use cases."""
|
||||||
|
|
||||||
def __init__(self, auth_service: AuthService) -> None:
|
def __init__(self, auth_service: AuthService) -> None:
|
||||||
|
"""Bind the servicer to the application auth service."""
|
||||||
self._auth_service = auth_service
|
self._auth_service = auth_service
|
||||||
|
|
||||||
async def RequestOtp(self, request, context): # type: ignore[no-untyped-def]
|
async def RequestOtp(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Handle public OTP request calls."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._auth_service.request_otp(
|
await self._auth_service.request_otp(
|
||||||
mobile=request.mobile,
|
mobile=request.mobile,
|
||||||
@@ -38,6 +43,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
|||||||
await _abort_for_app_error(context, exc)
|
await _abort_for_app_error(context, exc)
|
||||||
|
|
||||||
async def VerifyOtp(self, request, context): # type: ignore[no-untyped-def]
|
async def VerifyOtp(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Handle public OTP verification and token issuance calls."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token_pair = await self._auth_service.verify_otp(
|
token_pair = await self._auth_service.verify_otp(
|
||||||
mobile=request.mobile,
|
mobile=request.mobile,
|
||||||
@@ -49,6 +56,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
|||||||
await _abort_for_app_error(context, exc)
|
await _abort_for_app_error(context, exc)
|
||||||
|
|
||||||
async def RefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
async def RefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Handle refresh-token rotation calls."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token_pair = await self._auth_service.refresh_token(request.refresh_token)
|
token_pair = await self._auth_service.refresh_token(request.refresh_token)
|
||||||
return _token_response(token_pair)
|
return _token_response(token_pair)
|
||||||
@@ -56,6 +65,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
|||||||
await _abort_for_app_error(context, exc)
|
await _abort_for_app_error(context, exc)
|
||||||
|
|
||||||
async def RevokeRefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
async def RevokeRefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Handle authenticated refresh-token revocation calls."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = _extract_bearer_token(context.invocation_metadata())
|
token = _extract_bearer_token(context.invocation_metadata())
|
||||||
await self._auth_service.get_authenticated_user(token)
|
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)
|
await _abort_for_app_error(context, exc)
|
||||||
|
|
||||||
async def PublicPing(self, request, context): # type: ignore[no-untyped-def]
|
async def PublicPing(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Return a public response without authentication."""
|
||||||
|
|
||||||
return auth_pb2.PingResponse(message="public ok")
|
return auth_pb2.PingResponse(message="public ok")
|
||||||
|
|
||||||
async def UserOnly(self, request, context): # type: ignore[no-untyped-def]
|
async def UserOnly(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Return a response for any active authenticated user."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await self._auth_service.get_authenticated_user(
|
user = await self._auth_service.get_authenticated_user(
|
||||||
_extract_bearer_token(context.invocation_metadata())
|
_extract_bearer_token(context.invocation_metadata())
|
||||||
@@ -77,6 +92,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
|||||||
await _abort_for_app_error(context, exc)
|
await _abort_for_app_error(context, exc)
|
||||||
|
|
||||||
async def AdminOnly(self, request, context): # type: ignore[no-untyped-def]
|
async def AdminOnly(self, request, context): # type: ignore[no-untyped-def]
|
||||||
|
"""Return a response only for authenticated admin users."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = await self._auth_service.require_role(
|
user = await self._auth_service.require_role(
|
||||||
_extract_bearer_token(context.invocation_metadata()), Role.ADMIN
|
_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]
|
def _token_response(token_pair: TokenPair): # type: ignore[no-untyped-def]
|
||||||
|
"""Convert an application token pair into a protobuf response."""
|
||||||
|
|
||||||
return auth_pb2.TokenResponse(
|
return auth_pb2.TokenResponse(
|
||||||
access_token=token_pair.access_token,
|
access_token=token_pair.access_token,
|
||||||
refresh_token=token_pair.refresh_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]
|
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)
|
return auth_pb2.ProtectedResponse(user_id=user.id, role=user.role.value, message=message)
|
||||||
|
|
||||||
|
|
||||||
def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str:
|
def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str:
|
||||||
|
"""Read bearer token metadata from a protected gRPC invocation."""
|
||||||
|
|
||||||
for key, value in metadata:
|
for key, value in metadata:
|
||||||
if key.lower() == "authorization" and value.startswith("Bearer "):
|
if key.lower() == "authorization" and value.startswith("Bearer "):
|
||||||
return value.removeprefix("Bearer ").strip()
|
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:
|
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):
|
if isinstance(exc, AuthenticationError | InvalidOtp | OtpExpired | OtpAttemptsExceeded):
|
||||||
await context.abort(grpc.StatusCode.UNAUTHENTICATED, str(exc))
|
await context.abort(grpc.StatusCode.UNAUTHENTICATED, str(exc))
|
||||||
if isinstance(exc, InactiveUser):
|
if isinstance(exc, InactiveUser):
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def serve() -> None:
|
async def serve() -> None:
|
||||||
|
"""Start the async gRPC auth server and own infrastructure lifecycle."""
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
rabbitmq = await connect_robust(settings.rabbitmq_url)
|
rabbitmq = await connect_robust(settings.rabbitmq_url)
|
||||||
@@ -26,6 +27,7 @@ async def serve() -> None:
|
|||||||
auth_servicer = AuthGrpcServicer(container.auth_service)
|
auth_servicer = AuthGrpcServicer(container.auth_service)
|
||||||
auth_pb2_grpc.add_AuthServiceServicer_to_server(auth_servicer, server)
|
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_servicer = health.HealthServicer()
|
||||||
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
|
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
|
||||||
service_names = (
|
service_names = (
|
||||||
|
|||||||
@@ -20,46 +20,73 @@ T = TypeVar("T")
|
|||||||
|
|
||||||
|
|
||||||
class AuthClient(Protocol):
|
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(
|
async def revoke_refresh_token(
|
||||||
self, access_token: str, refresh_token: str
|
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):
|
class RequestOtpBody(BaseModel):
|
||||||
|
"""HTTP body for starting the mobile OTP login flow."""
|
||||||
|
|
||||||
mobile: str = Field(min_length=10, max_length=16)
|
mobile: str = Field(min_length=10, max_length=16)
|
||||||
purpose: str = "login"
|
purpose: str = "login"
|
||||||
|
|
||||||
|
|
||||||
class VerifyOtpBody(RequestOtpBody):
|
class VerifyOtpBody(RequestOtpBody):
|
||||||
|
"""HTTP body for verifying a received OTP code."""
|
||||||
|
|
||||||
code: str = Field(min_length=6, max_length=6)
|
code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
class RefreshBody(BaseModel):
|
class RefreshBody(BaseModel):
|
||||||
|
"""HTTP body carrying the opaque refresh token."""
|
||||||
|
|
||||||
refresh_token: str = Field(min_length=20)
|
refresh_token: str = Field(min_length=20)
|
||||||
|
|
||||||
|
|
||||||
class TokenBody(BaseModel):
|
class TokenBody(BaseModel):
|
||||||
|
"""HTTP body carrying a bearer access token for demo actions."""
|
||||||
|
|
||||||
access_token: str = Field(min_length=20)
|
access_token: str = Field(min_length=20)
|
||||||
|
|
||||||
|
|
||||||
class RevokeBody(TokenBody):
|
class RevokeBody(TokenBody):
|
||||||
|
"""HTTP body for revoking the current refresh session."""
|
||||||
|
|
||||||
refresh_token: str = Field(min_length=20)
|
refresh_token: str = Field(min_length=20)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
"""Create and close shared outbound clients for the demo service."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target)
|
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:
|
def get_auth_client(request: Request) -> AuthClient:
|
||||||
|
"""Resolve the configured auth-service client from application state."""
|
||||||
return cast(AuthClient, request.app.state.auth_client)
|
return cast(AuthClient, request.app.state.auth_client)
|
||||||
|
|
||||||
|
|
||||||
def get_app_settings(request: Request) -> Settings:
|
def get_app_settings(request: Request) -> Settings:
|
||||||
|
"""Resolve immutable runtime settings for request handlers."""
|
||||||
return cast(Settings, request.app.state.settings)
|
return cast(Settings, request.app.state.settings)
|
||||||
|
|
||||||
|
|
||||||
def get_debug_redis(request: Request) -> Redis | None:
|
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)
|
return cast(Redis | None, request.app.state.debug_redis)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index() -> FileResponse:
|
||||||
|
"""Serve the single-page browser demo."""
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
|
"""Return a lightweight readiness response for Compose and Caddy checks."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +139,7 @@ async def request_otp(
|
|||||||
body: RequestOtpBody,
|
body: RequestOtpBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""Forward an OTP request from the browser to the gRPC auth service."""
|
||||||
return cast(
|
return cast(
|
||||||
dict[str, object],
|
dict[str, object],
|
||||||
await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)),
|
await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)),
|
||||||
@@ -118,6 +151,7 @@ async def verify_otp(
|
|||||||
body: VerifyOtpBody,
|
body: VerifyOtpBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> 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))
|
response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose))
|
||||||
return cast(dict[str, object], asdict(response))
|
return cast(dict[str, object], asdict(response))
|
||||||
|
|
||||||
@@ -127,6 +161,7 @@ async def refresh_token(
|
|||||||
body: RefreshBody,
|
body: RefreshBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""Rotate a refresh token and return a new token pair."""
|
||||||
response = await _call_grpc(lambda: client.refresh_token(body.refresh_token))
|
response = await _call_grpc(lambda: client.refresh_token(body.refresh_token))
|
||||||
return cast(dict[str, object], asdict(response))
|
return cast(dict[str, object], asdict(response))
|
||||||
|
|
||||||
@@ -136,6 +171,7 @@ async def revoke_refresh_token(
|
|||||||
body: RevokeBody,
|
body: RevokeBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""Revoke the current refresh session through auth-service."""
|
||||||
return cast(
|
return cast(
|
||||||
dict[str, object],
|
dict[str, object],
|
||||||
await _call_grpc(
|
await _call_grpc(
|
||||||
@@ -146,6 +182,7 @@ async def revoke_refresh_token(
|
|||||||
|
|
||||||
@app.post("/api/demo/public")
|
@app.post("/api/demo/public")
|
||||||
async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]:
|
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))
|
return cast(dict[str, object], await _call_grpc(client.public_ping))
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +191,7 @@ async def user_demo(
|
|||||||
body: TokenBody,
|
body: TokenBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> 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)))
|
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,
|
body: TokenBody,
|
||||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||||
) -> dict[str, object]:
|
) -> 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)))
|
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)],
|
settings: Annotated[Settings, Depends(get_app_settings)],
|
||||||
redis: Annotated[Redis | None, Depends(get_debug_redis)],
|
redis: Annotated[Redis | None, Depends(get_debug_redis)],
|
||||||
) -> dict[str, str | None]:
|
) -> 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:
|
if not settings.demo_enable_debug_otp or redis is None:
|
||||||
raise HTTPException(status_code=404, detail="debug OTP endpoint is disabled")
|
raise HTTPException(status_code=404, detail="debug OTP endpoint is disabled")
|
||||||
code = await redis.get(debug_sms_key(mobile))
|
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:
|
async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T:
|
||||||
|
"""Execute a gRPC call and translate transport errors to HTTP errors."""
|
||||||
try:
|
try:
|
||||||
return await call()
|
return await call()
|
||||||
except grpc.aio.AioRpcError as exc:
|
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:
|
def _grpc_to_http_status(code: grpc.StatusCode) -> int:
|
||||||
|
"""Map auth-service gRPC status codes to browser-friendly HTTP statuses."""
|
||||||
match code:
|
match code:
|
||||||
case grpc.StatusCode.INVALID_ARGUMENT:
|
case grpc.StatusCode.INVALID_ARGUMENT:
|
||||||
return 400
|
return 400
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from gapido_auth.generated import auth_pb2, auth_pb2_grpc
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DemoTokenResponse:
|
class DemoTokenResponse:
|
||||||
|
"""Token payload shape returned by the demo BFF to the browser."""
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
@@ -17,32 +19,40 @@ class DemoTokenResponse:
|
|||||||
|
|
||||||
|
|
||||||
class AuthGrpcClient:
|
class AuthGrpcClient:
|
||||||
|
"""Thin async gRPC client used by the FastAPI demo service."""
|
||||||
|
|
||||||
def __init__(self, target: str) -> None:
|
def __init__(self, target: str) -> None:
|
||||||
|
"""Open an async channel to auth-service."""
|
||||||
self._channel = grpc.aio.insecure_channel(target)
|
self._channel = grpc.aio.insecure_channel(target)
|
||||||
self._stub = auth_pb2_grpc.AuthServiceStub(self._channel)
|
self._stub = auth_pb2_grpc.AuthServiceStub(self._channel)
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying gRPC channel during FastAPI shutdown."""
|
||||||
await self._channel.close()
|
await self._channel.close()
|
||||||
|
|
||||||
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]:
|
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(
|
response = await self._stub.RequestOtp(
|
||||||
auth_pb2.RequestOtpRequest(mobile=mobile, purpose=purpose)
|
auth_pb2.RequestOtpRequest(mobile=mobile, purpose=purpose)
|
||||||
)
|
)
|
||||||
return {"accepted": bool(response.accepted)}
|
return {"accepted": bool(response.accepted)}
|
||||||
|
|
||||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse:
|
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(
|
response = await self._stub.VerifyOtp(
|
||||||
auth_pb2.VerifyOtpRequest(mobile=mobile, code=code, purpose=purpose)
|
auth_pb2.VerifyOtpRequest(mobile=mobile, code=code, purpose=purpose)
|
||||||
)
|
)
|
||||||
return _token_response(response)
|
return _token_response(response)
|
||||||
|
|
||||||
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse:
|
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse:
|
||||||
|
"""Refresh and rotate an opaque refresh token."""
|
||||||
response = await self._stub.RefreshToken(
|
response = await self._stub.RefreshToken(
|
||||||
auth_pb2.RefreshTokenRequest(refresh_token=refresh_token)
|
auth_pb2.RefreshTokenRequest(refresh_token=refresh_token)
|
||||||
)
|
)
|
||||||
return _token_response(response)
|
return _token_response(response)
|
||||||
|
|
||||||
async def revoke_refresh_token(self, access_token: str, refresh_token: str) -> dict[str, bool]:
|
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(
|
response = await self._stub.RevokeRefreshToken(
|
||||||
auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token),
|
auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token),
|
||||||
metadata=_auth_metadata(access_token),
|
metadata=_auth_metadata(access_token),
|
||||||
@@ -50,16 +60,19 @@ class AuthGrpcClient:
|
|||||||
return {"revoked": bool(response.revoked)}
|
return {"revoked": bool(response.revoked)}
|
||||||
|
|
||||||
async def public_ping(self) -> dict[str, str]:
|
async def public_ping(self) -> dict[str, str]:
|
||||||
|
"""Call the public demonstration endpoint."""
|
||||||
response = await self._stub.PublicPing(auth_pb2.PingRequest())
|
response = await self._stub.PublicPing(auth_pb2.PingRequest())
|
||||||
return {"message": str(response.message)}
|
return {"message": str(response.message)}
|
||||||
|
|
||||||
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-only demonstration endpoint."""
|
||||||
response = await self._stub.UserOnly(
|
response = await self._stub.UserOnly(
|
||||||
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
||||||
)
|
)
|
||||||
return _protected_response(response)
|
return _protected_response(response)
|
||||||
|
|
||||||
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-only demonstration endpoint."""
|
||||||
response = await self._stub.AdminOnly(
|
response = await self._stub.AdminOnly(
|
||||||
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
||||||
)
|
)
|
||||||
@@ -67,10 +80,12 @@ class AuthGrpcClient:
|
|||||||
|
|
||||||
|
|
||||||
def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]:
|
def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""Build gRPC bearer metadata expected by auth-service."""
|
||||||
return (("authorization", f"Bearer {access_token}"),)
|
return (("authorization", f"Bearer {access_token}"),)
|
||||||
|
|
||||||
|
|
||||||
def _token_response(response: Any) -> DemoTokenResponse:
|
def _token_response(response: Any) -> DemoTokenResponse:
|
||||||
|
"""Convert a protobuf token message into a dataclass."""
|
||||||
return DemoTokenResponse(
|
return DemoTokenResponse(
|
||||||
access_token=str(response.access_token),
|
access_token=str(response.access_token),
|
||||||
refresh_token=str(response.refresh_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]:
|
def _protected_response(response: Any) -> dict[str, str]:
|
||||||
|
"""Convert a protected protobuf response into a JSON-ready dict."""
|
||||||
return {
|
return {
|
||||||
"user_id": str(response.user_id),
|
"user_id": str(response.user_id),
|
||||||
"role": str(response.role),
|
"role": str(response.role),
|
||||||
|
|||||||
Reference in New Issue
Block a user