From d43eb54e2d59cfbf61da0bdb2dd91c7cc32c6631 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 14 Jul 2026 10:12:22 +0330 Subject: [PATCH] feat(demo): add auth flow UI service --- .env.example | 7 +- README.md | 14 +- docker-compose.yml | 19 +- pyproject.toml | 2 + src/gapido_auth/config.py | 8 +- src/gapido_auth/demo/__init__.py | 2 + src/gapido_auth/demo/app.py | 197 ++++++++++++++++++ src/gapido_auth/demo/grpc_client.py | 88 ++++++++ src/gapido_auth/demo/static/app.js | 139 ++++++++++++ src/gapido_auth/demo/static/index.html | 65 ++++++ src/gapido_auth/demo/static/styles.css | 164 +++++++++++++++ .../infrastructure/debug_sms_client.py | 26 +++ .../infrastructure/sms_provider.py | 9 +- src/gapido_auth/infrastructure/worker.py | 13 +- tests/test_debug_sms_client.py | 20 ++ tests/test_demo_app.py | 132 ++++++++++++ 16 files changed, 895 insertions(+), 10 deletions(-) create mode 100644 src/gapido_auth/demo/__init__.py create mode 100644 src/gapido_auth/demo/app.py create mode 100644 src/gapido_auth/demo/grpc_client.py create mode 100644 src/gapido_auth/demo/static/app.js create mode 100644 src/gapido_auth/demo/static/index.html create mode 100644 src/gapido_auth/demo/static/styles.css create mode 100644 src/gapido_auth/infrastructure/debug_sms_client.py create mode 100644 tests/test_debug_sms_client.py create mode 100644 tests/test_demo_app.py diff --git a/.env.example b/.env.example index 62125ae..1056677 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,16 @@ OTP_TTL_SECONDS=120 OTP_MAX_ATTEMPTS=5 OTP_REQUEST_LIMIT=3 OTP_REQUEST_WINDOW_SECONDS=300 -SMS_PROVIDER=kavenegar +SMS_PROVIDER=debug KAVENEGAR_API_KEY=replace-with-real-key KAVENEGAR_LOGIN_TEMPLATE=login-otp SMS_IR_API_KEY=replace-with-real-key SMS_IR_VERIFY_TEMPLATE_ID=570574 +AUTH_GRPC_TARGET=localhost:50051 +DEMO_ENABLE_DEBUG_OTP=true +DEMO_DEBUG_SMS_TTL_SECONDS=300 +DEMO_HOST=0.0.0.0 +DEMO_PORT=8080 ADMIN_MOBILE=989120000000 GRPC_HOST=0.0.0.0 GRPC_PORT=50051 diff --git a/README.md b/README.md index 832c81f..a18b5e2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ cp .env.example .env docker compose up --build ``` -The gRPC service listens on `localhost:50051`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`. +The gRPC service listens on `localhost:50051`. The demo UI is available at `http://localhost:8080`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`. ## SMS Provider @@ -31,6 +31,18 @@ Supported values: - `kavenegar`: uses `KAVENEGAR_API_KEY` and `KAVENEGAR_LOGIN_TEMPLATE`. - `sms_ir`: uses `SMS_IR_API_KEY` and `SMS_IR_VERIFY_TEMPLATE_ID`. +- `debug`: local-only provider that stores the latest OTP in Redis for the demo UI. + +## Demo UI + +`demo-app` is a small FastAPI backend-for-frontend that calls `auth-service` over gRPC. It demonstrates how another microservice consumes the auth service. + +Available browser actions: + +- Request and verify OTP. +- Fetch the local debug OTP when `DEMO_ENABLE_DEBUG_OTP=true`. +- Call public, authenticated-user, and admin-only gRPC methods. +- Refresh and revoke tokens. ## Local Development diff --git a/docker-compose.yml b/docker-compose.yml index b1536c1..2c30844 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5} OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3} OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300} - SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} + SMS_PROVIDER: ${SMS_PROVIDER:-debug} KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp} SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574} ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000} @@ -38,13 +38,28 @@ services: MONGO_URI: mongodb://mongo:27017 REDIS_URL: redis://redis:6379/0 RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ - SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} + SMS_PROVIDER: ${SMS_PROVIDER:-debug} KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key} SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key} depends_on: rabbitmq: condition: service_healthy + demo-app: + build: . + command: uvicorn gapido_auth.demo.app:app --host 0.0.0.0 --port 8080 + environment: + AUTH_GRPC_TARGET: auth-service:50051 + REDIS_URL: redis://redis:6379/0 + DEMO_ENABLE_DEBUG_OTP: "true" + ports: + - "8080:8080" + depends_on: + auth-service: + condition: service_started + redis: + condition: service_started + mongo: image: mongo:7 ports: diff --git a/pyproject.toml b/pyproject.toml index 6b58a25..0be599c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Gapido backend code challenge: gRPC OTP auth service with MongoDB requires-python = ">=3.12,<3.14" dependencies = [ "aio-pika==9.5.5", + "fastapi==0.115.6", "grpcio==1.68.1", "grpcio-health-checking==1.68.1", "grpcio-reflection==1.68.1", @@ -16,6 +17,7 @@ dependencies = [ "pydantic-settings==2.7.1", "pyjwt==2.10.1", "redis==5.2.1", + "uvicorn==0.34.0", ] [project.optional-dependencies] diff --git a/src/gapido_auth/config.py b/src/gapido_auth/config.py index a8a3c5d..0d6778c 100644 --- a/src/gapido_auth/config.py +++ b/src/gapido_auth/config.py @@ -23,7 +23,7 @@ class Settings(BaseSettings): otp_request_limit: int = 3 otp_request_window_seconds: int = 300 - sms_provider: Literal["kavenegar", "sms_ir"] = "kavenegar" + sms_provider: Literal["kavenegar", "sms_ir", "debug"] = "kavenegar" kavenegar_api_key: str = "replace-with-real-key" kavenegar_login_template: str = "login-otp" @@ -31,6 +31,12 @@ class Settings(BaseSettings): sms_ir_api_key: str = "replace-with-real-key" sms_ir_verify_template_id: int = 570574 + auth_grpc_target: str = "localhost:50051" + demo_enable_debug_otp: bool = False + demo_debug_sms_ttl_seconds: int = 300 + demo_host: str = "0.0.0.0" + demo_port: int = 8080 + admin_mobile: str = "989120000000" grpc_host: str = "0.0.0.0" grpc_port: int = 50051 diff --git a/src/gapido_auth/demo/__init__.py b/src/gapido_auth/demo/__init__.py new file mode 100644 index 0000000..73e7137 --- /dev/null +++ b/src/gapido_auth/demo/__init__.py @@ -0,0 +1,2 @@ +"""Demo browser client for the auth service.""" + diff --git a/src/gapido_auth/demo/app.py b/src/gapido_auth/demo/app.py new file mode 100644 index 0000000..b0c15e2 --- /dev/null +++ b/src/gapido_auth/demo/app.py @@ -0,0 +1,197 @@ +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import asdict +from pathlib import Path +from typing import Annotated, Protocol, TypeVar, cast + +import grpc +from fastapi import Depends, FastAPI, HTTPException, Query, Request +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field +from redis.asyncio import Redis + +from gapido_auth.config import Settings, get_settings +from gapido_auth.demo.grpc_client import AuthGrpcClient, DemoTokenResponse +from gapido_auth.infrastructure.debug_sms_client import debug_sms_key + +STATIC_DIR = Path(__file__).resolve().parent / "static" +T = TypeVar("T") + + +class AuthClient(Protocol): + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: ... + + async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: ... + + async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: ... + + async def revoke_refresh_token( + self, access_token: str, refresh_token: str + ) -> dict[str, bool]: ... + + async def public_ping(self) -> dict[str, str]: ... + + async def user_only(self, access_token: str) -> dict[str, str]: ... + + async def admin_only(self, access_token: str) -> dict[str, str]: ... + + +class RequestOtpBody(BaseModel): + mobile: str = Field(min_length=10, max_length=16) + purpose: str = "login" + + +class VerifyOtpBody(RequestOtpBody): + code: str = Field(min_length=6, max_length=6) + + +class RefreshBody(BaseModel): + refresh_token: str = Field(min_length=20) + + +class TokenBody(BaseModel): + access_token: str = Field(min_length=20) + + +class RevokeBody(TokenBody): + refresh_token: str = Field(min_length=20) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + app.state.settings = settings + app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target) + app.state.debug_redis = ( + Redis.from_url(settings.redis_url, decode_responses=True) + if settings.demo_enable_debug_otp + else None + ) + try: + yield + finally: + await app.state.auth_client.close() + if app.state.debug_redis is not None: + await app.state.debug_redis.aclose() + + +app = FastAPI(title="Gapido Auth Demo", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +def get_auth_client(request: Request) -> AuthClient: + return cast(AuthClient, request.app.state.auth_client) + + +def get_app_settings(request: Request) -> Settings: + return cast(Settings, request.app.state.settings) + + +def get_debug_redis(request: Request) -> Redis | None: + return cast(Redis | None, request.app.state.debug_redis) + + +@app.get("/") +async def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.post("/api/auth/request-otp") +async def request_otp( + body: RequestOtpBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast( + dict[str, object], + await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)), + ) + + +@app.post("/api/auth/verify-otp") +async def verify_otp( + body: VerifyOtpBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose)) + return cast(dict[str, object], asdict(response)) + + +@app.post("/api/auth/refresh") +async def refresh_token( + body: RefreshBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + response = await _call_grpc(lambda: client.refresh_token(body.refresh_token)) + return cast(dict[str, object], asdict(response)) + + +@app.post("/api/auth/revoke") +async def revoke_refresh_token( + body: RevokeBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast( + dict[str, object], + await _call_grpc( + lambda: client.revoke_refresh_token(body.access_token, body.refresh_token) + ), + ) + + +@app.post("/api/demo/public") +async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(client.public_ping)) + + +@app.post("/api/demo/user") +async def user_demo( + body: TokenBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(lambda: client.user_only(body.access_token))) + + +@app.post("/api/demo/admin") +async def admin_demo( + body: TokenBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(lambda: client.admin_only(body.access_token))) + + +@app.get("/api/debug/otp") +async def debug_otp( + mobile: Annotated[str, Query(min_length=10, max_length=16)], + settings: Annotated[Settings, Depends(get_app_settings)], + redis: Annotated[Redis | None, Depends(get_debug_redis)], +) -> dict[str, str | None]: + 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)) + return {"mobile": mobile, "code": code} + + +async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T: + try: + return await call() + except grpc.aio.AioRpcError as exc: + status_code = _grpc_to_http_status(exc.code()) + detail = exc.details() or exc.code().name + raise HTTPException(status_code=status_code, detail=detail) from exc + + +def _grpc_to_http_status(code: grpc.StatusCode) -> int: + match code: + case grpc.StatusCode.INVALID_ARGUMENT: + return 400 + case grpc.StatusCode.UNAUTHENTICATED: + return 401 + case grpc.StatusCode.PERMISSION_DENIED: + return 403 + case grpc.StatusCode.RESOURCE_EXHAUSTED: + return 429 + case grpc.StatusCode.UNAVAILABLE: + return 503 + case _: + return 502 diff --git a/src/gapido_auth/demo/grpc_client.py b/src/gapido_auth/demo/grpc_client.py new file mode 100644 index 0000000..b6af1b8 --- /dev/null +++ b/src/gapido_auth/demo/grpc_client.py @@ -0,0 +1,88 @@ +# mypy: disable-error-code="attr-defined,no-untyped-call" +from dataclasses import dataclass +from typing import Any + +import grpc + +from gapido_auth.generated import auth_pb2, auth_pb2_grpc + + +@dataclass(frozen=True, slots=True) +class DemoTokenResponse: + access_token: str + refresh_token: str + token_type: str + expires_in: int + role: str + + +class AuthGrpcClient: + def __init__(self, target: str) -> None: + self._channel = grpc.aio.insecure_channel(target) + self._stub = auth_pb2_grpc.AuthServiceStub(self._channel) + + async def close(self) -> None: + await self._channel.close() + + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: + 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: + 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: + 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]: + response = await self._stub.RevokeRefreshToken( + auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token), + metadata=_auth_metadata(access_token), + ) + return {"revoked": bool(response.revoked)} + + async def public_ping(self) -> dict[str, str]: + response = await self._stub.PublicPing(auth_pb2.PingRequest()) + return {"message": str(response.message)} + + async def user_only(self, access_token: str) -> dict[str, str]: + 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]: + response = await self._stub.AdminOnly( + auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token) + ) + return _protected_response(response) + + +def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]: + return (("authorization", f"Bearer {access_token}"),) + + +def _token_response(response: Any) -> DemoTokenResponse: + return DemoTokenResponse( + access_token=str(response.access_token), + refresh_token=str(response.refresh_token), + token_type=str(response.token_type), + expires_in=int(response.expires_in), + role=str(response.role), + ) + + +def _protected_response(response: Any) -> dict[str, str]: + return { + "user_id": str(response.user_id), + "role": str(response.role), + "message": str(response.message), + } diff --git a/src/gapido_auth/demo/static/app.js b/src/gapido_auth/demo/static/app.js new file mode 100644 index 0000000..943328d --- /dev/null +++ b/src/gapido_auth/demo/static/app.js @@ -0,0 +1,139 @@ +const state = { + accessToken: sessionStorage.getItem("accessToken") || "", + refreshToken: sessionStorage.getItem("refreshToken") || "", + role: sessionStorage.getItem("role") || "", + expiresIn: sessionStorage.getItem("expiresIn") || "", +}; + +const $ = (id) => document.getElementById(id); + +function render() { + $("auth-status").textContent = state.accessToken ? "Signed in" : "Signed out"; + $("role").textContent = state.role || "-"; + $("ttl").textContent = state.expiresIn ? `${state.expiresIn}s` : "-"; + $("token-preview").textContent = state.accessToken ? `${state.accessToken.slice(0, 24)}...` : "-"; +} + +function saveTokens(tokens) { + state.accessToken = tokens.access_token; + state.refreshToken = tokens.refresh_token; + state.role = tokens.role; + state.expiresIn = String(tokens.expires_in); + sessionStorage.setItem("accessToken", state.accessToken); + sessionStorage.setItem("refreshToken", state.refreshToken); + sessionStorage.setItem("role", state.role); + sessionStorage.setItem("expiresIn", state.expiresIn); + render(); +} + +function clearTokens() { + state.accessToken = ""; + state.refreshToken = ""; + state.role = ""; + state.expiresIn = ""; + sessionStorage.clear(); + render(); +} + +function show(data) { + $("result").textContent = JSON.stringify(data, null, 2); +} + +async function api(path, body = undefined, method = "POST") { + const response = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json(); + if (!response.ok) { + throw data; + } + return data; +} + +$("request-otp").addEventListener("click", async () => { + try { + const data = await api("/api/auth/request-otp", { mobile: $("mobile").value, purpose: "login" }); + show(data); + } catch (error) { + show(error); + } +}); + +$("debug-otp").addEventListener("click", async () => { + try { + const mobile = encodeURIComponent($("mobile").value); + const data = await api(`/api/debug/otp?mobile=${mobile}`, undefined, "GET"); + if (data.code) $("otp").value = data.code; + show(data); + } catch (error) { + show(error); + } +}); + +$("login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const tokens = await api("/api/auth/verify-otp", { + mobile: $("mobile").value, + code: $("otp").value, + purpose: "login", + }); + saveTokens(tokens); + show({ signed_in: true, role: tokens.role }); + } catch (error) { + show(error); + } +}); + +$("refresh").addEventListener("click", async () => { + try { + const tokens = await api("/api/auth/refresh", { refresh_token: state.refreshToken }); + saveTokens(tokens); + show({ refreshed: true, role: tokens.role }); + } catch (error) { + show(error); + } +}); + +$("logout").addEventListener("click", async () => { + try { + const data = await api("/api/auth/revoke", { + access_token: state.accessToken, + refresh_token: state.refreshToken, + }); + clearTokens(); + show(data); + } catch (error) { + clearTokens(); + show(error); + } +}); + +$("public").addEventListener("click", async () => { + try { + show(await api("/api/demo/public")); + } catch (error) { + show(error); + } +}); + +$("user").addEventListener("click", async () => { + try { + show(await api("/api/demo/user", { access_token: state.accessToken })); + } catch (error) { + show(error); + } +}); + +$("admin").addEventListener("click", async () => { + try { + show(await api("/api/demo/admin", { access_token: state.accessToken })); + } catch (error) { + show(error); + } +}); + +render(); + diff --git a/src/gapido_auth/demo/static/index.html b/src/gapido_auth/demo/static/index.html new file mode 100644 index 0000000..380f8f7 --- /dev/null +++ b/src/gapido_auth/demo/static/index.html @@ -0,0 +1,65 @@ + + + + + + Gapido Auth Demo + + + +
+
+
+

Microservice Auth Demo

+

Gapido gRPC Auth

+
+
Signed out
+
+ +
+
+

OTP Login

+ +
+ + +
+ + +
+ +
+

Session

+
+
Role
-
+
Access TTL
-
+
Token
-
+
+
+ + +
+
+
+ +
+

Service Calls

+
+ + + +
+
{ "ready": true }
+
+
+ + + + + diff --git a/src/gapido_auth/demo/static/styles.css b/src/gapido_auth/demo/static/styles.css new file mode 100644 index 0000000..35fd3cc --- /dev/null +++ b/src/gapido_auth/demo/static/styles.css @@ -0,0 +1,164 @@ +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f5f7fb; + color: #1d2433; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input { + font: inherit; +} + +.shell { + width: min(1080px, calc(100vw - 32px)); + margin: 32px auto; + display: grid; + gap: 16px; +} + +.panel { + background: #ffffff; + border: 1px solid #dbe3ef; + border-radius: 8px; + padding: 20px; + box-shadow: 0 16px 45px rgb(29 36 51 / 8%); +} + +.identity { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.eyebrow { + margin: 0 0 4px; + font-size: 13px; + color: #5f6f89; + font-weight: 700; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: 32px; +} + +h2 { + font-size: 18px; +} + +.status { + border: 1px solid #c4d3ea; + border-radius: 999px; + padding: 8px 12px; + color: #315477; + background: #eef5ff; + font-weight: 700; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.stack { + display: grid; + gap: 14px; +} + +label { + display: grid; + gap: 6px; + color: #40516b; + font-size: 14px; + font-weight: 700; +} + +input { + width: 100%; + border: 1px solid #cfd9e8; + border-radius: 6px; + padding: 11px 12px; + color: #1d2433; + background: #fbfdff; +} + +button { + border: 0; + border-radius: 6px; + padding: 11px 14px; + background: #206a5d; + color: #ffffff; + font-weight: 800; + cursor: pointer; +} + +button:hover { + background: #18544a; +} + +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.session { + margin: 0; + display: grid; + gap: 10px; +} + +.session div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 8px; +} + +dt { + color: #6b7b92; +} + +dd { + margin: 0; + overflow-wrap: anywhere; +} + +pre { + min-height: 180px; + margin: 0; + padding: 14px; + border-radius: 6px; + overflow: auto; + background: #111827; + color: #d1fae5; +} + +@media (max-width: 760px) { + .grid, + .identity { + grid-template-columns: 1fr; + display: grid; + } + + h1 { + font-size: 26px; + } +} + diff --git a/src/gapido_auth/infrastructure/debug_sms_client.py b/src/gapido_auth/infrastructure/debug_sms_client.py new file mode 100644 index 0000000..abbb79d --- /dev/null +++ b/src/gapido_auth/infrastructure/debug_sms_client.py @@ -0,0 +1,26 @@ +import logging +from typing import Protocol + +from gapido_auth.domain.ports import SmsClient + +logger = logging.getLogger(__name__) + + +class DebugSmsStore(Protocol): + async def setex(self, name: str, time: int, value: str) -> object: ... + + +class DebugSmsClient(SmsClient): + def __init__(self, store: DebugSmsStore, ttl_seconds: int) -> None: + self._store = store + self._ttl_seconds = ttl_seconds + + async def send_otp(self, mobile: str, code: str, template: str) -> None: + 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}" + diff --git a/src/gapido_auth/infrastructure/sms_provider.py b/src/gapido_auth/infrastructure/sms_provider.py index c96e2e3..589ca8d 100644 --- a/src/gapido_auth/infrastructure/sms_provider.py +++ b/src/gapido_auth/infrastructure/sms_provider.py @@ -2,6 +2,7 @@ import httpx from gapido_auth.config import Settings from gapido_auth.domain.ports import SmsClient +from gapido_auth.infrastructure.debug_sms_client import DebugSmsClient, DebugSmsStore from gapido_auth.infrastructure.kavenegar_client import KavenegarSmsClient from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient @@ -9,12 +10,17 @@ from gapido_auth.infrastructure.sms_ir_client import SmsIrSmsClient def create_sms_client( settings: Settings, transport: httpx.AsyncBaseTransport | None = None, + debug_store: DebugSmsStore | None = None, ) -> SmsClient: match settings.sms_provider: case "kavenegar": return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport) case "sms_ir": return SmsIrSmsClient(settings.sms_ir_api_key, transport=transport) + case "debug": + if debug_store is None: + raise ValueError("debug_store is required for debug SMS provider") + return DebugSmsClient(debug_store, ttl_seconds=settings.demo_debug_sms_ttl_seconds) def get_sms_template(settings: Settings) -> str: @@ -23,4 +29,5 @@ def get_sms_template(settings: Settings) -> str: return settings.kavenegar_login_template case "sms_ir": return str(settings.sms_ir_verify_template_id) - + case "debug": + return "debug-otp" diff --git a/src/gapido_auth/infrastructure/worker.py b/src/gapido_auth/infrastructure/worker.py index 8ede084..cf90b3c 100644 --- a/src/gapido_auth/infrastructure/worker.py +++ b/src/gapido_auth/infrastructure/worker.py @@ -5,6 +5,7 @@ from typing import Any, cast import aio_pika from aio_pika.abc import AbstractIncomingMessage +from redis.asyncio import Redis from gapido_auth.config import get_settings from gapido_auth.domain.entities import SmsJob @@ -69,10 +70,14 @@ async def main() -> None: channel = await connection.channel() await channel.set_qos(prefetch_count=20) exchange, queue, dlx = await declare_sms_topology(channel) - client = create_sms_client(settings) - await queue.consume(lambda message: handle_message(message, client, exchange, dlx)) - logger.info("SMS worker started with provider=%s", settings.sms_provider) - await asyncio.Future() + redis = Redis.from_url(settings.redis_url, decode_responses=True) + try: + client = create_sms_client(settings, debug_store=redis) + await queue.consume(lambda message: handle_message(message, client, exchange, dlx)) + logger.info("SMS worker started with provider=%s", settings.sms_provider) + await asyncio.Future() + finally: + await redis.aclose() if __name__ == "__main__": diff --git a/tests/test_debug_sms_client.py b/tests/test_debug_sms_client.py new file mode 100644 index 0000000..93fee16 --- /dev/null +++ b/tests/test_debug_sms_client.py @@ -0,0 +1,20 @@ +from gapido_auth.infrastructure.debug_sms_client import DebugSmsClient, debug_sms_key + + +class FakeDebugStore: + def __init__(self) -> None: + self.values: dict[str, tuple[int, str]] = {} + + async def setex(self, name: str, time: int, value: str) -> object: + self.values[name] = (time, value) + return True + + +async def test_debug_sms_client_stores_latest_otp() -> None: + store = FakeDebugStore() + client = DebugSmsClient(store, ttl_seconds=300) + + await client.send_otp("989120000000", "123456", "debug-otp") + + assert store.values[debug_sms_key("989120000000")] == (300, "123456") + diff --git a/tests/test_demo_app.py b/tests/test_demo_app.py new file mode 100644 index 0000000..0904219 --- /dev/null +++ b/tests/test_demo_app.py @@ -0,0 +1,132 @@ +from dataclasses import dataclass + +from fastapi.testclient import TestClient + +from gapido_auth.config import Settings +from gapido_auth.demo.app import app, get_app_settings, get_auth_client, get_debug_redis +from gapido_auth.demo.grpc_client import DemoTokenResponse + + +@dataclass(slots=True) +class FakeAuthClient: + last_user_token: str | None = None + last_admin_token: str | None = None + + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: + assert mobile == "989120000000" + assert purpose == "login" + return {"accepted": True} + + async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: + assert mobile == "989120000000" + assert code == "123456" + assert purpose == "login" + return DemoTokenResponse( + "access-token-value-123", "refresh-token-value-123", "Bearer", 900, "user" + ) + + async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: + assert refresh_token == "refresh-token-value-123" + return DemoTokenResponse( + "new-access-token-value", "new-refresh-token-value", "Bearer", 900, "user" + ) + + async def revoke_refresh_token(self, access_token: str, refresh_token: str) -> dict[str, bool]: + assert access_token == "access-token-value-123" + assert refresh_token == "refresh-token-value-123" + return {"revoked": True} + + async def public_ping(self) -> dict[str, str]: + return {"message": "public ok"} + + async def user_only(self, access_token: str) -> dict[str, str]: + self.last_user_token = access_token + return {"user_id": "1", "role": "user", "message": "authenticated user ok"} + + async def admin_only(self, access_token: str) -> dict[str, str]: + self.last_admin_token = access_token + return {"user_id": "2", "role": "admin", "message": "admin ok"} + + +class FakeDebugRedis: + async def get(self, key: str) -> str | None: + assert key == "debug:sms:last:989120000000" + return "123456" + + +def test_demo_auth_flow_forwards_to_grpc_client() -> None: + fake_client = FakeAuthClient() + app.dependency_overrides[get_auth_client] = lambda: fake_client + + client = TestClient(app) + try: + request = client.post( + "/api/auth/request-otp", json={"mobile": "989120000000", "purpose": "login"} + ) + assert request.status_code == 200 + assert request.json() == {"accepted": True} + + verify = client.post( + "/api/auth/verify-otp", + json={"mobile": "989120000000", "code": "123456", "purpose": "login"}, + ) + assert verify.status_code == 200 + assert verify.json()["access_token"] == "access-token-value-123" + + public = client.post("/api/demo/public") + assert public.status_code == 200 + assert public.json() == {"message": "public ok"} + + user = client.post("/api/demo/user", json={"access_token": "access-token-value-123"}) + assert user.status_code == 200 + assert fake_client.last_user_token == "access-token-value-123" + + admin = client.post("/api/demo/admin", json={"access_token": "access-token-value-123"}) + assert admin.status_code == 200 + assert fake_client.last_admin_token == "access-token-value-123" + + refresh = client.post( + "/api/auth/refresh", json={"refresh_token": "refresh-token-value-123"} + ) + assert refresh.status_code == 200 + assert refresh.json()["refresh_token"] == "new-refresh-token-value" + + revoke = client.post( + "/api/auth/revoke", + json={ + "access_token": "access-token-value-123", + "refresh_token": "refresh-token-value-123", + }, + ) + assert revoke.status_code == 200 + assert revoke.json() == {"revoked": True} + finally: + client.close() + app.dependency_overrides.clear() + + +def test_debug_otp_endpoint_reads_redis_when_enabled() -> None: + app.dependency_overrides[get_app_settings] = lambda: Settings(demo_enable_debug_otp=True) + app.dependency_overrides[get_debug_redis] = lambda: FakeDebugRedis() + + client = TestClient(app) + try: + response = client.get("/api/debug/otp?mobile=989120000000") + assert response.status_code == 200 + assert response.json() == {"mobile": "989120000000", "code": "123456"} + finally: + client.close() + app.dependency_overrides.clear() + + +def test_debug_otp_endpoint_is_disabled() -> None: + app.dependency_overrides[get_app_settings] = lambda: Settings(demo_enable_debug_otp=False) + app.dependency_overrides[get_debug_redis] = lambda: None + + client = TestClient(app) + try: + response = client.get("/api/debug/otp?mobile=989120000000") + assert response.status_code == 404 + finally: + client.close() + app.dependency_overrides.clear()